carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
16package release
17
18import (
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.
31const KeepFloor = 20
32
33// Retention is the wire shape at apps/<acct>/<app>/retention.json.
34type 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.
44func 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.
51func 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.
80func 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.
117func 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}
126func 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}
133func 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