console: retention doors — GET/POST /api/cli/apps/{account}/{app}/retention
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+346
−0
internal/console/apiretention.go+162 −0internal/console/apiretention_test.go+161 −0internal/console/authcli.go+6 −0internal/console/view_test.go+17 −0
diff --git a/internal/console/apiretention.go b/internal/console/apiretention.go| new file mode 100644 |
| index 000000000..9800da875 |
| --- /dev/null |
| +++ b/internal/console/apiretention.go |
| @@ -0,0 +1,162 @@ |
| +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) |
| + } |
| + 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 |
| + } |
| + if !s.cli.write.allow(addr, 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 |
| + } |
| + 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) |
| + } |
| +} |
diff --git a/internal/console/apiretention_test.go b/internal/console/apiretention_test.go| new file mode 100644 |
| index 000000000..cf6e6e066 |
| --- /dev/null |
| +++ b/internal/console/apiretention_test.go |
| @@ -0,0 +1,161 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func retentionFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A MEMBER: retention is app config, like env set (spec §7 ruling). |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +func getRetention(t *testing.T, u, bearer string) (int, []byte) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, u, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + body, _ := io.ReadAll(res.Body) |
| + return res.StatusCode, body |
| +} |
| + |
| +func TestAPIRetentionSetWritesAuditsAndAnnounces(t *testing.T) { |
| + fx, token := retentionFixture(t, "code-ret-set") |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, |
| + `{"keep": 50}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("set = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Keep int `json:"keep"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Account != "bab" || res.App != "titogo" || res.Keep != 50 { |
| + t.Fatalf("set body = %+v", res) |
| + } |
| + ret, err := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil || ret == nil || ret.Keep != 50 { |
| + t.Fatalf("record after set = %+v, %v; want keep 50", ret, err) |
| + } |
| + if ret.SetBy != "alice@keymail.dev" { |
| + t.Fatalf("SetBy = %q, want the caller's address", ret.SetBy) |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial = %d, want 1", got) |
| + } |
| +} |
| + |
| +func TestAPIRetentionFloorIs400(t *testing.T) { |
| + fx, token := retentionFixture(t, "code-ret-floor") |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, |
| + `{"keep": 5}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("keep 5 = %d %s, want 400", status, out) |
| + } |
| + if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil { |
| + t.Fatal("a refused set still wrote a record") |
| + } |
| +} |
| + |
| +func TestAPIRetentionOffClearsTheRecord(t *testing.T) { |
| + fx, token := retentionFixture(t, "code-ret-off") |
| + if err := release.SetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo", 50, "seed", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, `{"off": true}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("off = %d %s, want 200", status, out) |
| + } |
| + if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil { |
| + t.Fatalf("record after off = %+v, want nil", ret) |
| + } |
| +} |
| + |
| +func TestAPIRetentionDryRunWritesNothing(t *testing.T) { |
| + fx, token := retentionFixture(t, "code-ret-dry") |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, |
| + `{"keep": 20, "dry_run": true}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("dry-run = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + DryRun bool `json:"dry_run"` |
| + EligibleVersions []string `json:"eligible_versions"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !res.DryRun { |
| + t.Fatalf("dry-run body = %s, want dry_run true", out) |
| + } |
| + if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil { |
| + t.Fatal("dry-run wrote a record") |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after dry-run = %d, want 0 (nothing to announce)", got) |
| + } |
| +} |
| + |
| +func TestAPIRetentionGetShowsPolicyAndOffState(t *testing.T) { |
| + fx, token := retentionFixture(t, "code-ret-get") |
| + status, out := getRetention(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("get (off) = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + Keep int `json:"keep"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Keep != 0 { |
| + t.Fatalf("keep with no policy = %d, want 0", res.Keep) |
| + } |
| + if err := release.SetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo", 25, "seed", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out = getRetention(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("get = %d %s, want 200", status, out) |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Keep != 25 { |
| + t.Fatalf("keep = %d, want 25", res.Keep) |
| + } |
| +} |
| + |
| +func TestAPIRetentionUnknownAppIsPlain404(t *testing.T) { |
| + fx, token := retentionFixture(t, "code-ret-404") |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nothere/retention", token, `{"keep": 50}`) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("unknown app = %d %s, want the plain 404", status, out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 29b0facbb..2a5a42f48 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -230,6 +230,12 @@ func (s *Server) registerCLIRoutes() { |
| // promote and rollback, and the only one whose subject is a running |
| // process rather than a pointer. |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/restart", s.handleAPIRestart) |
| + // `carlos releases retention` (issue #136): a member's ambient-prune |
| + // policy — read, set, clear, preview. Same identity gates as every |
| + // door here; the safety list in release.PruneEligible is what keeps a |
| + // policy from ever deleting anything promoted, tagged, or recent. |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/retention", s.handleAPIRetentionGet) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/retention", s.handleAPIRetentionSet) |
| // The version-target, tags and releases-list API (apiversion.go, |
| // release versioning spec 2026-08-08). GET .../releases coexists with |
| // the POST above on the SAME path — a distinct method route, not a |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 97242954a..2d4f94e25 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -43,6 +43,23 @@ func (d *deletableStore) Delete(_ context.Context, key string) error { |
| return nil |
| } |
| +// ListMod forwards to the wrapped store's ModLister. Both production stores |
| +// (DirStore, S3Store) implement it; deletableStore's embedded field is |
| +// declared as the release.Store INTERFACE, so Go's promotion rules only |
| +// carry that interface's own methods onto deletableStore — ListMod, being |
| +// outside it, is otherwise silently dropped even though the concrete value |
| +// underneath answers it. release.PruneEligible fails closed without a |
| +// ModLister, and the console door this fixture exercises (issue #136) is a |
| +// real consumer of it, so the double must forward rather than lie about |
| +// what a production store can do. |
| +func (d *deletableStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) { |
| + ml, ok := d.Store.(release.ModLister) |
| + if !ok { |
| + return nil, fmt.Errorf("deletablestore: wrapped store has no ListMod") |
| + } |
| + return ml.ListMod(ctx, prefix) |
| +} |
| + |
| // buildFixtures makes a little platform: a registry with routes for apps |
| // kass (ready instance on stable) and otherapp, and a deployment bucket with |
| // kass v1 promoted to stable and v2 to canary/x. Shared with server_test.go. |