| new file mode 100644 |
| index 000000000..fb5184146 |
| --- /dev/null |
| +++ b/internal/release/retention.go |
| @@ -0,0 +1,125 @@ |
| +// The retention policy record: one small unsigned object per app that says |
| +// "keep the newest N shipped releases; the rest may be pruned" (issue #136, |
| +// spec 2026-08-10-releases-retention-design.md §3). Unsigned like |
| +// restart-gen.json because it carries bounded authority: the safety list in |
| +// prune.go — channel pointers, rollback history, tags, the bake ceiling — |
| +// always wins over it, so the worst a forged record can do is delete old, |
| +// unpromoted, untagged CI churn sooner than intended. |
| +// |
| +// Bucket layout addition: |
| +// |
| +// apps/<acct>/<app>/retention.json CAS-guarded, schema-1 policy record |
| +// |
| +// ABSENT IS LEGAL FOREVER: no record means retention is off — the |
| +// instances.json convention. Old boxes that predate this feature ignore the |
| +// key entirely; the wire shape is purely additive. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| +) |
| + |
| +// KeepFloor is the smallest keep a retention policy may name, matching the |
| +// CLI's releasesCap and the console's versionsCap (both 20): the floor |
| +// guarantees a pruned hole can never appear inside what either listing |
| +// shows. |
| +const KeepFloor = 20 |
| + |
| +// Retention is the wire shape at apps/<acct>/<app>/retention.json. |
| +type Retention struct { |
| + Schema int `json:"schema"` // 1 |
| + App string `json:"app"` // must agree with the key path |
| + Keep int `json:"keep"` // >= KeepFloor |
| + SetBy string `json:"set_by,omitempty"` |
| + SetAt time.Time `json:"set_at"` |
| +} |
| + |
| +// RetentionKey is the bucket key of app's retention record, derived from |
| +// AppPrefix like every other per-app key. |
| +func RetentionKey(acct, app string) string { return AppPrefix(acct, app) + "retention.json" } |
| + |
| +// GetRetention reads the app's retention policy. (nil, nil) when absent — |
| +// retention off is a legal permanent state, ReadAppTombstone's convention. |
| +// A record that lies about identity, carries an unknown schema, or names a |
| +// keep below the floor is refused, never repaired: the enforcement pass |
| +// must skip the app (fail closed), not guess. |
| +func GetRetention(ctx context.Context, s Store, acct, app string) (*Retention, error) { |
| + key := RetentionKey(acct, app) |
| + raw, _, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var ret Retention |
| + if err := json.Unmarshal(raw, &ret); err != nil { |
| + return nil, fmt.Errorf("retention at %s: %w", key, err) |
| + } |
| + if ret.Schema != 1 { |
| + return nil, fmt.Errorf("retention at %s: unknown schema %d", key, ret.Schema) |
| + } |
| + if ret.App != app { |
| + return nil, fmt.Errorf("retention at %s names app %q", key, ret.App) |
| + } |
| + if ret.Keep < KeepFloor { |
| + return nil, fmt.Errorf("retention at %s: keep %d below floor %d", key, ret.Keep, KeepFloor) |
| + } |
| + return &ret, nil |
| +} |
| + |
| +// SetRetention writes the policy, replacing any existing one. CAS with a |
| +// short retry: two members racing a set is last-writer-wins, which is the |
| +// right answer for a policy knob — but a conflict must re-read the etag, |
| +// never blind-write. |
| +func SetRetention(ctx context.Context, s Store, acct, app string, keep int, setBy string, now time.Time) error { |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("retention: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return fmt.Errorf("retention: invalid app %q", app) |
| + } |
| + if keep < KeepFloor { |
| + return fmt.Errorf("retention: keep %d below floor %d", keep, KeepFloor) |
| + } |
| + raw, err := json.Marshal(Retention{Schema: 1, App: app, Keep: keep, SetBy: setBy, SetAt: now.UTC()}) |
| + if err != nil { |
| + return err |
| + } |
| + key := RetentionKey(acct, app) |
| + var lastErr error |
| + for attempt := 0; attempt < 3; attempt++ { |
| + _, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + etag = "" |
| + } else if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| + if errors.Is(err, ErrCASConflict) { |
| + lastErr = err |
| + continue |
| + } |
| + return err |
| + } |
| + return nil |
| + } |
| + return fmt.Errorf("retention: could not write %s: %w", key, lastErr) |
| +} |
| + |
| +// ClearRetention turns retention off by deleting the record. Idempotent by |
| +// the store's own Delete contract. |
| +func ClearRetention(ctx context.Context, s Store, acct, app string) error { |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("retention: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return fmt.Errorf("retention: invalid app %q", app) |
| + } |
| + return s.Delete(ctx, RetentionKey(acct, app)) |
| +} |