cli: carlos releases retention — set/show/clear/dry-run the ambient prune policy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+242
−0
apimode.go+49 −0main.go+7 −0retention.go+129 −0retention_cli_test.go+57 −0
diff --git a/apimode.go b/apimode.go| index ad4daf82e..72f056642 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -453,6 +453,55 @@ func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRes |
| return res, nil |
| } |
| +// apiRetentionResult mirrors the console's retention answer: the policy as |
| +// recorded, plus the console's own count of currently-eligible versions — |
| +// computed console-side because this terminal has no bucket. Eligible is a |
| +// pointer: nil means the console could not compute the count, which the CLI |
| +// says out loud rather than printing a lying zero. |
| +type apiRetentionResult struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Keep int `json:"keep"` |
| + Off bool `json:"off"` |
| + DryRun bool `json:"dry_run"` |
| + Eligible *int `json:"eligible"` |
| + Total int `json:"total"` |
| + EligibleVersions []string `json:"eligible_versions"` |
| +} |
| + |
| +func (s *apiSession) retention(ctx context.Context, a apiAcct, app string) (apiRetentionResult, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/retention")) |
| + return decodeRetention(status, body, err) |
| +} |
| + |
| +func (s *apiSession) setRetention(ctx context.Context, a apiAcct, app string, keep int, off, dryRun bool) (apiRetentionResult, error) { |
| + in := struct { |
| + Keep int `json:"keep,omitempty"` |
| + Off bool `json:"off,omitempty"` |
| + DryRun bool `json:"dry_run,omitempty"` |
| + }{keep, off, dryRun} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/retention"), in) |
| + return decodeRetention(status, body, err) |
| +} |
| + |
| +func decodeRetention(status int, body []byte, err error) (apiRetentionResult, error) { |
| + if err != nil { |
| + return apiRetentionResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiRetentionResult{}, fmt.Errorf("%w — or this console predates `carlos releases retention` "+ |
| + "(deploy a console built from current main)", apiErrorFromBody(status, body)) |
| + } |
| + return apiRetentionResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiRetentionResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiRetentionResult{}, fmt.Errorf("retention: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // channels reads one app's channel ladder through the console. |
| func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]apiChannel, error) { |
| status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels")) |
diff --git a/main.go b/main.go| index 58b38f113..0fd85bf63 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -64,6 +64,13 @@ func main() { |
| case "channels": |
| runChannels(args) |
| case "releases": |
| + // One sub-verb, `retention` — the `carlos version target` judgment |
| + // call: flags-only verbs stay flat until a word genuinely names a |
| + // different action, and a policy write is not a listing. |
| + if len(args) > 0 && args[0] == "retention" { |
| + runReleasesRetention(args[1:]) |
| + return |
| + } |
| runReleases(args) |
| case "rollback": |
| runRollback(args) |
diff --git a/retention.go b/retention.go| new file mode 100644 |
| index 000000000..f031cded9 |
| --- /dev/null |
| +++ b/retention.go |
| @@ -0,0 +1,129 @@ |
| +// retention.go is `carlos releases retention`: the ambient-prune policy for |
| +// an app's shipped releases (issue #136). Set it once and the platform |
| +// keeps the newest N forever after — the safety list (channel pointers, |
| +// rollback history, tags, the 72h bake ceiling, each box's own adopted |
| +// versions) always wins over the number, so nothing promoted or recent is |
| +// ever deleted by policy. |
| +// |
| +// API mode ONLY, like restart and for its reason: the policy is a console |
| +// write with an audit row, and the eligibility preview runs console-side. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func runReleasesRetention(args []string) { |
| + fs := flag.NewFlagSet("releases retention", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose retention policy to read or write") |
| + keep := fs.Int("keep", 0, fmt.Sprintf("retain the newest N releases (minimum %d)", release.KeepFloor)) |
| + off := fs.Bool("off", false, "clear the retention policy — keep releases forever again") |
| + dryRun := fs.Bool("dry-run", false, "with -keep: list what would be pruned, write nothing") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + name := strings.TrimSpace(*app) |
| + usage := "usage: carlos releases retention -app <a> [-keep <n> [-dry-run] | -off] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]" |
| + if name == "" { |
| + log.Fatal(usage) |
| + } |
| + if err := validAppName(name); err != nil { |
| + log.Fatalf("releases retention: %v", err) |
| + } |
| + // Client-side gates before the server's: a request the console is |
| + // certain to refuse should cost a typo nothing. |
| + if *off && (*keep != 0 || *dryRun) { |
| + log.Fatal("releases retention: -off cannot be combined with -keep or -dry-run") |
| + } |
| + if !*off && *keep != 0 && *keep < release.KeepFloor { |
| + log.Fatalf("releases retention: -keep must be at least %d — the CLI and console always "+ |
| + "show the newest %d releases, and a pruned hole must never appear inside that window", |
| + release.KeepFloor, release.KeepFloor) |
| + } |
| + if *dryRun && *keep == 0 { |
| + log.Fatal("releases retention: -dry-run needs -keep <n> to preview") |
| + } |
| + |
| + sess := requireRetentionAPISession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + ctx := context.Background() |
| + switch { |
| + case *off: |
| + res, err := sess.setRetention(ctx, acct, name, 0, true, false) |
| + if err != nil { |
| + log.Fatalf("releases retention: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s for %s/%s — releases are kept forever again\n", |
| + colorFor(stdout).ok("retention cleared"), res.Account, res.App) |
| + case *keep == 0: |
| + res, err := sess.retention(ctx, acct, name) |
| + if err != nil { |
| + log.Fatalf("releases retention: %v", err) |
| + } |
| + if res.Keep == 0 { |
| + mutedf(stdout, "retention for %s: off — releases are kept forever\n", res.App) |
| + mutedf(stdout, "enable with: carlos releases retention -app %s -keep <n>\n", res.App) |
| + return |
| + } |
| + fmt.Fprintf(stdout, "retention for %s: keep %d\n", res.App, res.Keep) |
| + printEligible(res) |
| + case *dryRun: |
| + res, err := sess.setRetention(ctx, acct, name, *keep, false, true) |
| + if err != nil { |
| + log.Fatalf("releases retention: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s — keep %d would prune:\n", colorFor(stdout).ok("dry run"), res.Keep) |
| + for _, v := range res.EligibleVersions { |
| + fmt.Fprintf(stdout, " %s\n", v) |
| + } |
| + printEligible(res) |
| + mutedf(stdout, "nothing was written; drop -dry-run to set the policy\n") |
| + default: |
| + res, err := sess.setRetention(ctx, acct, name, *keep, false, false) |
| + if err != nil { |
| + log.Fatalf("releases retention: %v", err) |
| + } |
| + // "set", not "pruned": boxes enforce on their own slow loop, and this |
| + // terminal is not watching them do it (restart.go's honesty rule). |
| + fmt.Fprintf(stdout, "%s for %s/%s: keep %d\n", |
| + colorFor(stdout).ok("retention set"), res.Account, res.App, res.Keep) |
| + printEligible(res) |
| + mutedf(stdout, "verify later with: carlos releases -app %s\n", res.App) |
| + } |
| +} |
| + |
| +// printEligible reports the console's eligibility count honestly: a nil |
| +// count is SAID to be unavailable, never rendered as a zero. |
| +func printEligible(res apiRetentionResult) { |
| + switch { |
| + case res.Eligible == nil: |
| + mutedf(stdout, "eligible count unavailable right now\n") |
| + case res.Total > 0: |
| + fmt.Fprintf(stdout, "%d of %d versions currently eligible; boxes prune within ~10 minutes\n", |
| + *res.Eligible, res.Total) |
| + default: |
| + fmt.Fprintf(stdout, "%d versions currently eligible; boxes prune within ~10 minutes\n", *res.Eligible) |
| + } |
| +} |
| + |
| +// requireRetentionAPISession refuses direct-bucket mode with retention's OWN |
| +// message, requireRestartAPISession's pattern and reason. |
| +func requireRetentionAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos releases retention works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). The policy is a console write with an\n" + |
| + "audit row and a console-side preview. Unset the store env and run: carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
diff --git a/retention_cli_test.go b/retention_cli_test.go| new file mode 100644 |
| index 000000000..c015e73e2 |
| --- /dev/null |
| +++ b/retention_cli_test.go |
| @@ -0,0 +1,57 @@ |
| +package main |
| + |
| +// The CLI-side gates for `carlos releases retention` (issue #136): these |
| +// fail before any network call, so they run against the real binary rather |
| +// than a fake console. |
| + |
| +import ( |
| + "os/exec" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// runRetentionCLI shells the built binary with the given args from a |
| +// scratch dir with the masked test env (the 40-accounts rule). Deviation |
| +// from the brief: there is no `carlosBin(t)` helper in this package: the |
| +// existing per-test build helper is `buildCarlos(t)` (auth_e2e_test.go), |
| +// which restart_test.go's own CLI-gate test already uses — followed |
| +// verbatim here rather than inventing a second name for the same thing. |
| +func runRetentionCLI(t *testing.T, args ...string) (string, error) { |
| + t.Helper() |
| + bin := buildCarlos(t) |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t) |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| +} |
| + |
| +func TestReleasesRetentionRefusesBelowFloorClientSide(t *testing.T) { |
| + out, err := runRetentionCLI(t, "releases", "retention", "-app", "kass", "-keep", "5") |
| + if err == nil { |
| + t.Fatalf("keep 5 exited 0: %s", out) |
| + } |
| + if !strings.Contains(out, "20") { |
| + t.Fatalf("floor refusal must name the floor, got: %s", out) |
| + } |
| +} |
| + |
| +func TestReleasesRetentionRefusesOffPlusKeep(t *testing.T) { |
| + out, err := runRetentionCLI(t, "releases", "retention", "-app", "kass", "-keep", "50", "-off") |
| + if err == nil { |
| + t.Fatalf("-off with -keep exited 0: %s", out) |
| + } |
| + if !strings.Contains(out, "-off") { |
| + t.Fatalf("refusal should name the conflict, got: %s", out) |
| + } |
| +} |
| + |
| +func TestReleasesRetentionNeedsApp(t *testing.T) { |
| + out, err := runRetentionCLI(t, "releases", "retention") |
| + if err == nil { |
| + t.Fatalf("no -app exited 0: %s", out) |
| + } |
| + if !strings.Contains(out, "usage:") { |
| + t.Fatalf("want usage line, got: %s", out) |
| + } |
| +} |