package console // The `carlos releases retention` doors (issue #136). Gate order is // handleAPIRestart's exactly — token, one byte-identical 404 for every // identity failure, the shared per-address write budget, 503 when the // console has no bucket — and the serial bump comes strictly after a // successful write. Member-reachable by ruling (spec §7): retention is app // config, and the safety list in release.PruneEligible is the guardrail — // nothing promoted, rollback-reachable, tagged, or newer than the bake // ceiling is ever eligible, whoever sets the policy. // // The GET (and the eligible counts on every answer) run the SAME selector // the boxes run, minus box-local registry pins — so the console's preview // can only OVERCOUNT what a box will actually delete, never undercount. import ( "context" "encoding/json" "fmt" "log" "math" "net/http" "time" "github.com/carlosframework/platform/internal/release" ) // apiRetentionResponse is the one wire shape both doors answer with. type apiRetentionResponse struct { Account string `json:"account"` App string `json:"app"` Keep int `json:"keep"` Off bool `json:"off,omitempty"` DryRun bool `json:"dry_run,omitempty"` Eligible *int `json:"eligible,omitempty"` Total int `json:"total,omitempty"` EligibleVersions []string `json:"eligible_versions,omitempty"` } // fillEligible computes the counts best-effort: a selector error on a // successful set must not turn the set into a failure, so it logs and // leaves Eligible nil (the CLI says "count unavailable"). func (s *Server) fillEligible(ctx context.Context, resp *apiRetentionResponse, acct, app string, keep int) { eligible, err := release.PruneEligible(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, keep, time.Now(), nil) if err != nil { log.Printf("console: api retention eligible %s/%s: %v", acct, app, err) return } n := len(eligible) resp.Eligible = &n if versions, _, err := release.VersionCandidates(ctx, s.cfg.Releases, acct, app, math.MaxInt); err == nil { resp.Total = len(versions) } else { log.Printf("console: api retention total %s/%s: %v", acct, app, err) } resp.EligibleVersions = eligible } // handleAPIRetentionGet answers `carlos releases retention -app X` (show). // No write budget and no audit: it is a read. func (s *Server) handleAPIRetentionGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot read retention") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() ret, err := release.GetRetention(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api retention get %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the retention policy — try again") return } resp := apiRetentionResponse{Account: acct.Sqid, App: app} if ret != nil { resp.Keep = ret.Keep s.fillEligible(ctx, &resp, acct.Sqid, app, ret.Keep) resp.EligibleVersions = nil // show answers counts; the list is dry-run's } writeJSON(w, http.StatusOK, resp) } // handleAPIRetentionSet answers the write half: set, clear (off), or // preview (dry_run). Dry-run passes every gate INCLUDING the write budget — // it is a POST someone can loop — but writes, audits, and announces // nothing. func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot write retention") return } var in struct { Keep int `json:"keep"` Off bool `json:"off"` DryRun bool `json:"dry_run"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil { jsonError(w, http.StatusBadRequest, "unreadable request body") return } if in.Off && (in.Keep != 0 || in.DryRun) { jsonError(w, http.StatusBadRequest, "off cannot be combined with keep or dry_run") return } if !in.Off && in.Keep < release.KeepFloor { jsonError(w, http.StatusBadRequest, fmt.Sprintf( "keep must be at least %d — the console and CLI always show the newest %d releases, and a pruned hole must never appear inside that window", release.KeepFloor, release.KeepFloor)) return } // Retention is how the rollback DEPTH behind a control-plane app gets // shortened, so the branches that WRITE wear the promote pair's service // refusal: a CI credential must not quietly shrink the console's own // history. A PERSON still may — how much history to keep is an operator's // decision, and PruneEligible never touches anything promoted, tagged or // recent (controlplane.go). // // The dry run is exempt because it writes nothing, and a CI that cannot // see what a policy WOULD do has no way to bring a person a reviewed // proposal. The condition reads `in`, which is decoded once above and // never written again, and the switch below re-reads that same struct — // there is no second parse and no request field that could flip the // branch after this check. if !in.DryRun && s.refuseServiceControlPlane(w, p, acct.ID, app, "change its release retention") { return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() switch { case in.Off: if err := release.ClearRetention(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { log.Printf("console: api retention clear %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not clear the retention policy — nothing was changed; try again") return } s.audit(acct, app, addr, "retention", "cleared release retention (cli)") s.bumpSerial(ctx) writeJSON(w, http.StatusOK, apiRetentionResponse{Account: acct.Sqid, App: app, Off: true}) case in.DryRun: resp := apiRetentionResponse{Account: acct.Sqid, App: app, Keep: in.Keep, DryRun: true} eligible, err := release.PruneEligible(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, in.Keep, time.Now(), nil) if err != nil { log.Printf("console: api retention dry-run %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not compute the prune preview — nothing was written; try again") return } n := len(eligible) resp.Eligible, resp.EligibleVersions = &n, eligible if versions, _, err := release.VersionCandidates(ctx, s.cfg.Releases, acct.Sqid, app, math.MaxInt); err == nil { resp.Total = len(versions) } writeJSON(w, http.StatusOK, resp) default: if err := release.SetRetention(ctx, s.cfg.Releases, acct.Sqid, app, in.Keep, addr, time.Now()); err != nil { log.Printf("console: api retention set %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not write the retention policy — nothing was set; try again") return } s.audit(acct, app, addr, "retention", fmt.Sprintf("set release retention: keep %d (cli)", in.Keep)) s.bumpSerial(ctx) resp := apiRetentionResponse{Account: acct.Sqid, App: app, Keep: in.Keep} s.fillEligible(ctx, &resp, acct.Sqid, app, in.Keep) resp.EligibleVersions = nil // set answers counts; the list is dry-run's writeJSON(w, http.StatusOK, resp) } }