| 1 | // The retention policy record: one small unsigned object per app that says |
| 2 | // "keep the newest N shipped releases; the rest may be pruned" (issue #136, |
| 3 | // spec 2026-08-10-releases-retention-design.md §3). Unsigned like |
| 4 | // restart-gen.json because it carries bounded authority: the safety list in |
| 5 | // prune.go — channel pointers, rollback history, tags, the bake ceiling — |
| 6 | // always wins over it, so the worst a forged record can do is delete old, |
| 7 | // unpromoted, untagged CI churn sooner than intended. |
| 8 | // |
| 9 | // Bucket layout addition: |
| 10 | // |
| 11 | // apps/<acct>/<app>/retention.json CAS-guarded, schema-1 policy record |
| 12 | // |
| 13 | // ABSENT IS LEGAL FOREVER: no record means retention is off — the |
| 14 | // instances.json convention. Old boxes that predate this feature ignore the |
| 15 | // key entirely; the wire shape is purely additive. |
| 16 | package release |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "encoding/json" |
| 21 | "errors" |
| 22 | "fmt" |
| 23 | "os" |
| 24 | "time" |
| 25 | ) |
| 26 | |
| 27 | // KeepFloor is the smallest keep a retention policy may name, matching the |
| 28 | // CLI's releasesCap and the console's versionsCap (both 20): the floor |
| 29 | // guarantees a pruned hole can never appear inside what either listing |
| 30 | // shows. |
| 31 | const KeepFloor = 20 |
| 32 | |
| 33 | // Retention is the wire shape at apps/<acct>/<app>/retention.json. |
| 34 | type Retention struct { |
| 35 | Schema int `json:"schema"` // 1 |
| 36 | App string `json:"app"` // must agree with the key path |
| 37 | Keep int `json:"keep"` // >= KeepFloor |
| 38 | SetBy string `json:"set_by,omitempty"` |
| 39 | SetAt time.Time `json:"set_at"` |
| 40 | } |
| 41 | |
| 42 | // RetentionKey is the bucket key of app's retention record, derived from |
| 43 | // AppPrefix like every other per-app key. |
| 44 | func RetentionKey(acct, app string) string { return AppPrefix(acct, app) + "retention.json" } |
| 45 | |
| 46 | // GetRetention reads the app's retention policy. (nil, nil) when absent — |
| 47 | // retention off is a legal permanent state, ReadAppTombstone's convention. |
| 48 | // A record that lies about identity, carries an unknown schema, or names a |
| 49 | // keep below the floor is refused, never repaired: the enforcement pass |
| 50 | // must skip the app (fail closed), not guess. |
| 51 | func GetRetention(ctx context.Context, s Store, acct, app string) (*Retention, error) { |
| 52 | key := RetentionKey(acct, app) |
| 53 | raw, _, err := s.Get(ctx, key) |
| 54 | if errors.Is(err, os.ErrNotExist) { |
| 55 | return nil, nil |
| 56 | } |
| 57 | if err != nil { |
| 58 | return nil, err |
| 59 | } |
| 60 | var ret Retention |
| 61 | if err := json.Unmarshal(raw, &ret); err != nil { |
| 62 | return nil, fmt.Errorf("retention at %s: %w", key, err) |
| 63 | } |
| 64 | if ret.Schema != 1 { |
| 65 | return nil, fmt.Errorf("retention at %s: unknown schema %d", key, ret.Schema) |
| 66 | } |
| 67 | if ret.App != app { |
| 68 | return nil, fmt.Errorf("retention at %s names app %q", key, ret.App) |
| 69 | } |
| 70 | if ret.Keep < KeepFloor { |
| 71 | return nil, fmt.Errorf("retention at %s: keep %d below floor %d", key, ret.Keep, KeepFloor) |
| 72 | } |
| 73 | return &ret, nil |
| 74 | } |
| 75 | |
| 76 | // SetRetention writes the policy, replacing any existing one. CAS with a |
| 77 | // short retry: two members racing a set is last-writer-wins, which is the |
| 78 | // right answer for a policy knob — but a conflict must re-read the etag, |
| 79 | // never blind-write. |
| 80 | func claimRawSetRetention(ctx context.Context, s Store, acct, app string, keep int, setBy string, now time.Time) error { |
| 81 | if !ValidAccount(acct) { |
| 82 | return fmt.Errorf("retention: invalid account %q", acct) |
| 83 | } |
| 84 | if !ValidApp(app) { |
| 85 | return fmt.Errorf("retention: invalid app %q", app) |
| 86 | } |
| 87 | if keep < KeepFloor { |
| 88 | return fmt.Errorf("retention: keep %d below floor %d", keep, KeepFloor) |
| 89 | } |
| 90 | raw, err := json.Marshal(Retention{Schema: 1, App: app, Keep: keep, SetBy: setBy, SetAt: now.UTC()}) |
| 91 | if err != nil { |
| 92 | return err |
| 93 | } |
| 94 | key := RetentionKey(acct, app) |
| 95 | var lastErr error |
| 96 | for attempt := 0; attempt < 3; attempt++ { |
| 97 | _, etag, err := s.Get(ctx, key) |
| 98 | if errors.Is(err, os.ErrNotExist) { |
| 99 | etag = "" |
| 100 | } else if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| 104 | if errors.Is(err, ErrCASConflict) { |
| 105 | lastErr = err |
| 106 | continue |
| 107 | } |
| 108 | return err |
| 109 | } |
| 110 | return nil |
| 111 | } |
| 112 | return fmt.Errorf("retention: could not write %s: %w", key, lastErr) |
| 113 | } |
| 114 | |
| 115 | // ClearRetention turns retention off by deleting the record. Idempotent by |
| 116 | // the store's own Delete contract. |
| 117 | func claimRawClearRetention(ctx context.Context, s Store, acct, app string) error { |
| 118 | if !ValidAccount(acct) { |
| 119 | return fmt.Errorf("retention: invalid account %q", acct) |
| 120 | } |
| 121 | if !ValidApp(app) { |
| 122 | return fmt.Errorf("retention: invalid app %q", app) |
| 123 | } |
| 124 | return s.Delete(ctx, RetentionKey(acct, app)) |
| 125 | } |
| 126 | func ClearRetention(ctx context.Context, s Store, acct, app string) (claimResult0 error) { |
| 127 | claimResult0 = WithAppMutation(ctx, s, acct, app, "release.ClearRetention", func(claimContext context.Context) error { |
| 128 | claimResult0 = claimRawClearRetention(claimContext, s, acct, app) |
| 129 | return claimResult0 |
| 130 | }) |
| 131 | return |
| 132 | } |
| 133 | func SetRetention(ctx context.Context, s Store, acct, app string, keep int, setBy string, now time.Time) (claimResult0 error) { |
| 134 | claimResult0 = WithAppMutation(ctx, s, acct, app, "release.SetRetention", func(claimContext context.Context) error { |
| 135 | claimResult0 = claimRawSetRetention(claimContext, s, acct, app, keep, setBy, now) |
| 136 | return claimResult0 |
| 137 | }) |
| 138 | return |
| 139 | } |
| 140 | |