carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
10package main
11
12import (
13 "context"
14 "flag"
15 "fmt"
16 "log"
17 "strings"
18
19 "github.com/carlosframework/platform/internal/release"
20)
21
22func 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.
105func 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.
119func 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