carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
16import (
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.
29type 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").
43func (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.
61func (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.
92func (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