| 1 | package console |
| 2 | |
| 3 | // The `carlos releases retention` doors (issue #136). Gate order is |
| 4 | // handleAPIRestart's exactly — token, one byte-identical 404 for every |
| 5 | // identity failure, the shared per-address write budget, 503 when the |
| 6 | // console has no bucket — and the serial bump comes strictly after a |
| 7 | // successful write. Member-reachable by ruling (spec §7): retention is app |
| 8 | // config, and the safety list in release.PruneEligible is the guardrail — |
| 9 | // nothing promoted, rollback-reachable, tagged, or newer than the bake |
| 10 | // ceiling is ever eligible, whoever sets the policy. |
| 11 | // |
| 12 | // The GET (and the eligible counts on every answer) run the SAME selector |
| 13 | // the boxes run, minus box-local registry pins — so the console's preview |
| 14 | // can only OVERCOUNT what a box will actually delete, never undercount. |
| 15 | |
| 16 | import ( |
| 17 | "context" |
| 18 | "encoding/json" |
| 19 | "fmt" |
| 20 | "log" |
| 21 | "math" |
| 22 | "net/http" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | // apiRetentionResponse is the one wire shape both doors answer with. |
| 29 | type apiRetentionResponse struct { |
| 30 | Account string `json:"account"` |
| 31 | App string `json:"app"` |
| 32 | Keep int `json:"keep"` |
| 33 | Off bool `json:"off,omitempty"` |
| 34 | DryRun bool `json:"dry_run,omitempty"` |
| 35 | Eligible *int `json:"eligible,omitempty"` |
| 36 | Total int `json:"total,omitempty"` |
| 37 | EligibleVersions []string `json:"eligible_versions,omitempty"` |
| 38 | } |
| 39 | |
| 40 | // fillEligible computes the counts best-effort: a selector error on a |
| 41 | // successful set must not turn the set into a failure, so it logs and |
| 42 | // leaves Eligible nil (the CLI says "count unavailable"). |
| 43 | func (s *Server) fillEligible(ctx context.Context, resp *apiRetentionResponse, acct, app string, keep int) { |
| 44 | eligible, err := release.PruneEligible(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, keep, time.Now(), nil) |
| 45 | if err != nil { |
| 46 | log.Printf("console: api retention eligible %s/%s: %v", acct, app, err) |
| 47 | return |
| 48 | } |
| 49 | n := len(eligible) |
| 50 | resp.Eligible = &n |
| 51 | if versions, _, err := release.VersionCandidates(ctx, s.cfg.Releases, acct, app, math.MaxInt); err == nil { |
| 52 | resp.Total = len(versions) |
| 53 | } else { |
| 54 | log.Printf("console: api retention total %s/%s: %v", acct, app, err) |
| 55 | } |
| 56 | resp.EligibleVersions = eligible |
| 57 | } |
| 58 | |
| 59 | // handleAPIRetentionGet answers `carlos releases retention -app X` (show). |
| 60 | // No write budget and no audit: it is a read. |
| 61 | func (s *Server) handleAPIRetentionGet(w http.ResponseWriter, r *http.Request) { |
| 62 | _, acct, app, ok := s.apiAccountApp(w, r) |
| 63 | if !ok { |
| 64 | return |
| 65 | } |
| 66 | if s.cfg.Releases == nil { |
| 67 | jsonError(w, http.StatusServiceUnavailable, |
| 68 | "no deployment bucket on this host — this console cannot read retention") |
| 69 | return |
| 70 | } |
| 71 | ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| 72 | defer cancel() |
| 73 | ret, err := release.GetRetention(ctx, s.cfg.Releases, acct.Sqid, app) |
| 74 | if err != nil { |
| 75 | log.Printf("console: api retention get %s/%s: %v", acct.Sqid, app, err) |
| 76 | jsonError(w, http.StatusBadGateway, "could not read the retention policy — try again") |
| 77 | return |
| 78 | } |
| 79 | resp := apiRetentionResponse{Account: acct.Sqid, App: app} |
| 80 | if ret != nil { |
| 81 | resp.Keep = ret.Keep |
| 82 | s.fillEligible(ctx, &resp, acct.Sqid, app, ret.Keep) |
| 83 | resp.EligibleVersions = nil // show answers counts; the list is dry-run's |
| 84 | } |
| 85 | writeJSON(w, http.StatusOK, resp) |
| 86 | } |
| 87 | |
| 88 | // handleAPIRetentionSet answers the write half: set, clear (off), or |
| 89 | // preview (dry_run). Dry-run passes every gate INCLUDING the write budget — |
| 90 | // it is a POST someone can loop — but writes, audits, and announces |
| 91 | // nothing. |
| 92 | func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) { |
| 93 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 94 | if !ok { |
| 95 | return |
| 96 | } |
| 97 | p, _ := principalFrom(r.Context()) |
| 98 | if !s.allowWrite(p, time.Now()) { |
| 99 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 100 | return |
| 101 | } |
| 102 | if s.cfg.Releases == nil { |
| 103 | jsonError(w, http.StatusServiceUnavailable, |
| 104 | "no deployment bucket on this host — this console cannot write retention") |
| 105 | return |
| 106 | } |
| 107 | var in struct { |
| 108 | Keep int `json:"keep"` |
| 109 | Off bool `json:"off"` |
| 110 | DryRun bool `json:"dry_run"` |
| 111 | } |
| 112 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil { |
| 113 | jsonError(w, http.StatusBadRequest, "unreadable request body") |
| 114 | return |
| 115 | } |
| 116 | if in.Off && (in.Keep != 0 || in.DryRun) { |
| 117 | jsonError(w, http.StatusBadRequest, "off cannot be combined with keep or dry_run") |
| 118 | return |
| 119 | } |
| 120 | if !in.Off && in.Keep < release.KeepFloor { |
| 121 | jsonError(w, http.StatusBadRequest, fmt.Sprintf( |
| 122 | "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", |
| 123 | release.KeepFloor, release.KeepFloor)) |
| 124 | return |
| 125 | } |
| 126 | // Retention is how the rollback DEPTH behind a control-plane app gets |
| 127 | // shortened, so the branches that WRITE wear the promote pair's service |
| 128 | // refusal: a CI credential must not quietly shrink the console's own |
| 129 | // history. A PERSON still may — how much history to keep is an operator's |
| 130 | // decision, and PruneEligible never touches anything promoted, tagged or |
| 131 | // recent (controlplane.go). |
| 132 | // |
| 133 | // The dry run is exempt because it writes nothing, and a CI that cannot |
| 134 | // see what a policy WOULD do has no way to bring a person a reviewed |
| 135 | // proposal. The condition reads `in`, which is decoded once above and |
| 136 | // never written again, and the switch below re-reads that same struct — |
| 137 | // there is no second parse and no request field that could flip the |
| 138 | // branch after this check. |
| 139 | if !in.DryRun && s.refuseServiceControlPlane(w, p, acct.ID, app, "change its release retention") { |
| 140 | return |
| 141 | } |
| 142 | ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| 143 | defer cancel() |
| 144 | switch { |
| 145 | case in.Off: |
| 146 | if err := release.ClearRetention(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { |
| 147 | log.Printf("console: api retention clear %s/%s: %v", acct.Sqid, app, err) |
| 148 | jsonError(w, http.StatusBadGateway, "could not clear the retention policy — nothing was changed; try again") |
| 149 | return |
| 150 | } |
| 151 | s.audit(acct, app, addr, "retention", "cleared release retention (cli)") |
| 152 | s.bumpSerial(ctx) |
| 153 | writeJSON(w, http.StatusOK, apiRetentionResponse{Account: acct.Sqid, App: app, Off: true}) |
| 154 | case in.DryRun: |
| 155 | resp := apiRetentionResponse{Account: acct.Sqid, App: app, Keep: in.Keep, DryRun: true} |
| 156 | eligible, err := release.PruneEligible(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, in.Keep, time.Now(), nil) |
| 157 | if err != nil { |
| 158 | log.Printf("console: api retention dry-run %s/%s: %v", acct.Sqid, app, err) |
| 159 | jsonError(w, http.StatusBadGateway, "could not compute the prune preview — nothing was written; try again") |
| 160 | return |
| 161 | } |
| 162 | n := len(eligible) |
| 163 | resp.Eligible, resp.EligibleVersions = &n, eligible |
| 164 | if versions, _, err := release.VersionCandidates(ctx, s.cfg.Releases, acct.Sqid, app, math.MaxInt); err == nil { |
| 165 | resp.Total = len(versions) |
| 166 | } |
| 167 | writeJSON(w, http.StatusOK, resp) |
| 168 | default: |
| 169 | if err := release.SetRetention(ctx, s.cfg.Releases, acct.Sqid, app, in.Keep, addr, time.Now()); err != nil { |
| 170 | log.Printf("console: api retention set %s/%s: %v", acct.Sqid, app, err) |
| 171 | jsonError(w, http.StatusBadGateway, "could not write the retention policy — nothing was set; try again") |
| 172 | return |
| 173 | } |
| 174 | s.audit(acct, app, addr, "retention", fmt.Sprintf("set release retention: keep %d (cli)", in.Keep)) |
| 175 | s.bumpSerial(ctx) |
| 176 | resp := apiRetentionResponse{Account: acct.Sqid, App: app, Keep: in.Keep} |
| 177 | s.fillEligible(ctx, &resp, acct.Sqid, app, in.Keep) |
| 178 | resp.EligibleVersions = nil // set answers counts; the list is dry-run's |
| 179 | writeJSON(w, http.StatusOK, resp) |
| 180 | } |
| 181 | } |
| 182 | |