// 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 := appFlag(fs, "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 := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos releases retention --app [--keep [--dry-run] | --off] [--account ] [--account-id ] [--console ]" if name != "" { 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 to preview") } sess := requireRetentionAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) 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", 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 .") } return sess }