carlos / platform Public

console: retention doors — GET/POST /api/cli/apps/{account}/{app}/retention

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 39686c8721ee027ac77ca8ddb3506ee822835c2f parent 3f1293f
4 files changed, +346 −0
  • internal/console/apiretention.go +162 −0
  • internal/console/apiretention_test.go +161 −0
  • internal/console/authcli.go +6 −0
  • internal/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.