carlos / platform Public

cli: carlos releases retention — set/show/clear/dry-run the ambient prune policy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 85d813562fb27b0d6efcbe3bee282cf2e36dd77e parent 39686c8
4 files changed, +242 −0
  • apimode.go +49 −0
  • main.go +7 −0
  • retention.go +129 −0
  • retention_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)
+ }
+}