| 1 | // retention.go is `carlos releases retention`: the ambient-prune policy for |
| 2 | // an app's shipped releases (issue #136). Set it once and the platform |
| 3 | // keeps the newest N forever after — the safety list (channel pointers, |
| 4 | // rollback history, tags, the 72h bake ceiling, each box's own adopted |
| 5 | // versions) always wins over the number, so nothing promoted or recent is |
| 6 | // ever deleted by policy. |
| 7 | // |
| 8 | // API mode ONLY, like restart and for its reason: the policy is a console |
| 9 | // write with an audit row, and the eligibility preview runs console-side. |
| 10 | package main |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "flag" |
| 15 | "fmt" |
| 16 | "log" |
| 17 | "strings" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | func runReleasesRetention(args []string) { |
| 23 | fs := flag.NewFlagSet("releases retention", flag.ExitOnError) |
| 24 | app := appFlag(fs, "app whose retention policy to read or write") |
| 25 | keep := fs.Int("keep", 0, fmt.Sprintf("retain the newest N releases (minimum %d)", release.KeepFloor)) |
| 26 | off := fs.Bool("off", false, "clear the retention policy — keep releases forever again") |
| 27 | dryRun := fs.Bool("dry-run", false, "with --keep: list what would be pruned, write nothing") |
| 28 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 29 | consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| 30 | fs.Usage = flagUsage(fs, "") |
| 31 | fs.Parse(args) |
| 32 | name := strings.TrimSpace(*app) |
| 33 | usage := "usage: carlos releases retention --app <a> [--keep <n> [--dry-run] | --off] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| 34 | if name != "" { |
| 35 | if err := validAppName(name); err != nil { |
| 36 | log.Fatalf("releases retention: %v", err) |
| 37 | } |
| 38 | } |
| 39 | // Client-side gates before the server's: a request the console is |
| 40 | // certain to refuse should cost a typo nothing. |
| 41 | if *off && (*keep != 0 || *dryRun) { |
| 42 | log.Fatal("releases retention: --off cannot be combined with --keep or --dry-run") |
| 43 | } |
| 44 | if !*off && *keep != 0 && *keep < release.KeepFloor { |
| 45 | log.Fatalf("releases retention: --keep must be at least %d — the CLI and console always "+ |
| 46 | "show the newest %d releases, and a pruned hole must never appear inside that window", |
| 47 | release.KeepFloor, release.KeepFloor) |
| 48 | } |
| 49 | if *dryRun && *keep == 0 { |
| 50 | log.Fatal("releases retention: --dry-run needs --keep <n> to preview") |
| 51 | } |
| 52 | |
| 53 | sess := requireRetentionAPISession(*consoleFlag) |
| 54 | printUsing(stdout, sess) |
| 55 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 56 | name = resolvedApp(sess, acct, name, usage) |
| 57 | ctx := context.Background() |
| 58 | switch { |
| 59 | case *off: |
| 60 | res, err := sess.setRetention(ctx, acct, name, 0, true, false) |
| 61 | if err != nil { |
| 62 | log.Fatalf("releases retention: %v", err) |
| 63 | } |
| 64 | fmt.Fprintf(stdout, "%s for %s/%s — releases are kept forever again\n", |
| 65 | colorFor(stdout).ok("retention cleared"), res.Account, res.App) |
| 66 | case *keep == 0: |
| 67 | res, err := sess.retention(ctx, acct, name) |
| 68 | if err != nil { |
| 69 | log.Fatalf("releases retention: %v", err) |
| 70 | } |
| 71 | if res.Keep == 0 { |
| 72 | mutedf(stdout, "retention for %s: off — releases are kept forever\n", res.App) |
| 73 | mutedf(stdout, "enable with: carlos releases retention --app %s --keep <n>\n", res.App) |
| 74 | return |
| 75 | } |
| 76 | fmt.Fprintf(stdout, "retention for %s: keep %d\n", res.App, res.Keep) |
| 77 | printEligible(res) |
| 78 | case *dryRun: |
| 79 | res, err := sess.setRetention(ctx, acct, name, *keep, false, true) |
| 80 | if err != nil { |
| 81 | log.Fatalf("releases retention: %v", err) |
| 82 | } |
| 83 | fmt.Fprintf(stdout, "%s — keep %d would prune:\n", colorFor(stdout).ok("dry run"), res.Keep) |
| 84 | for _, v := range res.EligibleVersions { |
| 85 | fmt.Fprintf(stdout, " %s\n", v) |
| 86 | } |
| 87 | printEligible(res) |
| 88 | mutedf(stdout, "nothing was written; drop --dry-run to set the policy\n") |
| 89 | default: |
| 90 | res, err := sess.setRetention(ctx, acct, name, *keep, false, false) |
| 91 | if err != nil { |
| 92 | log.Fatalf("releases retention: %v", err) |
| 93 | } |
| 94 | // "set", not "pruned": boxes enforce on their own slow loop, and this |
| 95 | // terminal is not watching them do it (restart.go's honesty rule). |
| 96 | fmt.Fprintf(stdout, "%s for %s/%s: keep %d\n", |
| 97 | colorFor(stdout).ok("retention set"), res.Account, res.App, res.Keep) |
| 98 | printEligible(res) |
| 99 | mutedf(stdout, "verify later with: carlos releases --app %s\n", res.App) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // printEligible reports the console's eligibility count honestly: a nil |
| 104 | // count is SAID to be unavailable, never rendered as a zero. |
| 105 | func printEligible(res apiRetentionResult) { |
| 106 | switch { |
| 107 | case res.Eligible == nil: |
| 108 | mutedf(stdout, "eligible count unavailable right now\n") |
| 109 | case res.Total > 0: |
| 110 | fmt.Fprintf(stdout, "%d of %d versions currently eligible; boxes prune within ~10 minutes\n", |
| 111 | *res.Eligible, res.Total) |
| 112 | default: |
| 113 | fmt.Fprintf(stdout, "%d versions currently eligible; boxes prune within ~10 minutes\n", *res.Eligible) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // requireRetentionAPISession refuses direct-bucket mode with retention's OWN |
| 118 | // message, requireRestartAPISession's pattern and reason. |
| 119 | func requireRetentionAPISession(consoleFlag string) *apiSession { |
| 120 | sess, err := resolveMode(consoleFlag) |
| 121 | if err != nil { |
| 122 | log.Fatal(err) |
| 123 | } |
| 124 | if sess == nil { |
| 125 | log.Fatal("carlos releases retention works through a console, and this shell is in direct bucket mode\n" + |
| 126 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). The policy is a console write with an\n" + |
| 127 | "audit row and a console-side preview. Unset the store env and run: carlos auth login --console <url>.") |
| 128 | } |
| 129 | return sess |
| 130 | } |
| 131 | |