carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Ambient release retention, the box half (issue #136, spec
2// 2026-08-10-releases-retention-design.md §6): for each app this box has
3// routes for, read the app's retention record from the bucket and delete
4// what release.PruneEligible says is safe — protecting, on top of the
5// bucket-derived safety list, the versions THIS box's registry still
6// records as adopted, previous or good (the fallback). Boxes race each other harmlessly: every
7// delete is idempotent by store contract.
8//
9// Hosted-apps-only is a ruling, not an accident: an app with routes on no
10// box has nothing at stake, and whole-app trash→purge owns its end of life.
11package host
12
13import (
14 "context"
15 "crypto/ed25519"
16 "errors"
17 "fmt"
18 "log"
19 "strings"
20 "time"
21
22 "github.com/carlosframework/platform/internal/provision"
23 "github.com/carlosframework/platform/internal/registry"
24 "github.com/carlosframework/platform/internal/release"
25 "github.com/carlosframework/platform/internal/status"
26)
27
28// retentionPass runs one enforcement sweep. Per-app isolation: one app's
29// error is logged and skipped, never allowed to stop another app's
30// enforcement; a partial delete leaves the remainder for the next tick.
31//
32// reports is where the box reports are read (settings spec rev 11 §4.4's
33// build-state builds; retentionReports). A pass that cannot read them
34// prunes nothing at all.
35func retentionPass(ctx context.Context, store release.Store, reports *boxReports, pub ed25519.PublicKey, reg *registry.Registry) error {
36 routes, err := reg.List()
37 if err != nil {
38 return err
39 }
40 if reports == nil {
41 return errors.New("no box reports to read: pruning nothing")
42 }
43 boxes, complete, err := reports.load(ctx)
44 if err != nil {
45 return fmt.Errorf("reading the box reports: pruning nothing this pass: %w", err)
46 }
47 type appKey struct{ acct, app string }
48 pins := map[appKey]map[string]bool{}
49 for _, rt := range routes {
50 if rt.App == "" {
51 continue
52 }
53 k := appKey{release.RouteAccount(rt), rt.App}
54 if pins[k] == nil {
55 pins[k] = map[string]bool{}
56 }
57 if rt.Version != "" {
58 pins[k][rt.Version] = true
59 }
60 if rt.PrevVersion != "" {
61 pins[k][rt.PrevVersion] = true
62 }
63 // The good build is a planned wake's fallback (zero-downtime spec
64 // §8.5), and it can be neither Version nor PrevVersion once two
65 // releases have failed in a row. Pruned, the fallback has no artifact
66 // exactly when a failed release needs it (zdd-readiness Task 13,
67 // Astra R1 T12). Pinned whatever CARLOS_SWAP says, the plan's
68 // authorized flag-off exception: keeping one more version is the
69 // cheap side of the trade.
70 if rt.GoodVersion != "" {
71 pins[k][rt.GoodVersion] = true
72 }
73 }
74 for k, protected := range pins {
75 ret, err := release.GetRetention(ctx, store, k.acct, k.app)
76 if err != nil {
77 log.Printf("agent: retention %s/%s: %v", k.acct, k.app, err)
78 continue
79 }
80 if ret == nil {
81 continue
82 }
83 extra, err := settingsProtected(ctx, store, boxes, complete, time.Now(), k.acct, k.app)
84 if err != nil {
85 log.Printf("agent: retention %s/%s: pruning nothing this pass: %v", k.acct, k.app, err)
86 continue
87 }
88 for v := range extra {
89 protected[v] = true
90 }
91 eligible, err := release.PruneEligible(ctx, store, pub, k.acct, k.app, ret.Keep, time.Now(), protected)
92 if err != nil {
93 log.Printf("agent: retention %s/%s: %v", k.acct, k.app, err)
94 continue
95 }
96 if len(eligible) == 0 {
97 continue
98 }
99 deleted := 0
100 for _, v := range eligible {
101 if err := release.DeleteVersion(ctx, store, k.acct, k.app, v); err != nil {
102 log.Printf("agent: retention %s/%s: delete %s: %v", k.acct, k.app, v, err)
103 break
104 }
105 deleted++
106 }
107 log.Printf("agent: retention: pruned %d of %d eligible versions for %s/%s (keep %d)",
108 deleted, len(eligible), k.acct, k.app, ret.Keep)
109 }
110 return nil
111}
112
113// buildStatePins is this box's contribution to retention (settings spec rev
114// 11 §4.4): the build-state builds of every row keepsBuildState selects (the
115// rows refreshAll keeps a copy of), from the registry copies the agent
116// refreshes at reconcile and before every wake. claims is whether host
117// claims are on, as keepsBuildState takes it. Every such row is reported,
118// with or without builds, so a missing or unreadable copy shows as
119// incomplete rather than as nothing to protect. A failed list is an error:
120// the caller reports it (BoxMetrics.BuildStatesErr), never an empty map.
121func buildStatePins(reg routeLister, claims bool) (map[string]status.BuildStatePins, error) {
122 routes, err := reg.List()
123 if err != nil {
124 return nil, fmt.Errorf("listing routes for build states: %w", err)
125 }
126 out := map[string]status.BuildStatePins{}
127 for _, rt := range routes {
128 if !keepsBuildState(rt, claims) {
129 continue
130 }
131 p := status.BuildStatePins{Account: release.RouteAccount(rt), App: rt.App, Builds: []string{}}
132 c, err := release.ParseBuildStateCopy(rt.BuildState)
133 p.Complete = err == nil && c.Known && c.ReadErr == ""
134 if err == nil {
135 p.Builds = buildStateBuilds(c.State)
136 }
137 out[rt.Host] = p
138 }
139 if len(out) == 0 {
140 return nil, nil
141 }
142 return out, nil
143}
144
145// buildStateBuilds is the builds a build state names: last_executed, and a
146// resolved request's target when it differs. A failed or completed request's
147// T is named too: it may still be what the instance runs or recovers onto.
148func buildStateBuilds(st *release.BuildState) []string {
149 builds := []string{}
150 if st == nil {
151 return builds
152 }
153 if st.LastExecuted != "" {
154 builds = append(builds, st.LastExecuted)
155 }
156 if r := st.Resolved; r != nil && r.Target != "" && r.Target != st.LastExecuted {
157 builds = append(builds, r.Target)
158 }
159 return builds
160}
161
162// buildStateReportMaxAge is §4.4's bound: an app is not pruned while any box
163// hosting one of its instances has been silent longer than this.
164const buildStateReportMaxAge = 6 * time.Hour
165
166// boxReports is where a retention pass reads the box reports (settings spec
167// rev 11 §4.4). At least one of status and local is set.
168type boxReports struct {
169 // status is the store the boxes publish to (openStatusStore's bucket);
170 // nil for a sole housekeeper with none.
171 status release.Store
172 // prefix rebases the platform's own reports in status, as
173 // CARLOS_STATUS_PREFIX rebases what the status loop writes
174 // (resolveStatusPrefix). Fleet reports are not rebased: a fleet box
175 // writes at fleets/<fleet>/ from the bucket root.
176 prefix string
177 // deploy is the deployment store, read for fleets/ as well (ruling
178 // R-T9-FLEETS): a fleet box with no bucket publishes through the store
179 // door, which serves the deployment store. nil when it is the status
180 // store's own bucket.
181 deploy release.Store
182 // local is this box's own collector, read as a fresh report at every
183 // pass: set for a sole housekeeper (a directory-backed deployment, one
184 // box by construction, which publishes no status; ruling R-T9-SOLE).
185 local func() (map[string]status.BuildStatePins, error)
186 // now stamps the local report; nil is time.Now.
187 now func() time.Time
188}
189
190// soleReportLabel names a sole housekeeper's own report in refusals.
191const soleReportLabel = "this box (sole housekeeper)"
192
193// retentionReports is retentionLoop's report source, from the environment:
194// the status store (rebased by CARLOS_STATUS_PREFIX) and, for fleets, the
195// deployment store too when it is another bucket; or, on a sole
196// housekeeper with no status store, this box's own collector and the
197// deployment store's fleets. Anything else is an error: no app can be
198// pruned without the reports.
199func retentionReports(ctx context.Context, getenv func(string) string, deploy release.Store, local func() (map[string]status.BuildStatePins, error)) (*boxReports, error) {
200 st, _, err := openStatusStore(ctx, getenv)
201 switch {
202 case err == nil:
203 r := &boxReports{status: st, prefix: resolveStatusPrefix(getenv), deploy: deploy}
204 if b := getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" && b == resolveStatusBucket(getenv) {
205 r.deploy = nil // the same bucket: its fleets/ is read once
206 }
207 return r, nil
208 case errors.Is(err, errNoStatusStore) && newHousekeeper(getenv, nil).mode == housekeeperSole && local != nil:
209 return &boxReports{deploy: deploy, local: local}, nil
210 }
211 return nil, err
212}
213
214// load reads every box report: the platform's (status/boxes/ under prefix,
215// or this box's own on a sole housekeeper), and every fleet's in the status
216// store and in the deployment store, the union (a report in both is read
217// twice; the newest-per-host rule is indifferent to that). complete is
218// false if any report or fleets listing could not be read. Failing to list
219// the platform's own reports is an error.
220func (r *boxReports) load(ctx context.Context) ([]status.Box, bool, error) {
221 if r == nil || (r.status == nil && r.local == nil) {
222 return nil, false, errors.New("no box reports to read")
223 }
224 var boxes []status.Box
225 complete := true
226 if r.status != nil {
227 root, ok, err := status.LoadBoxesComplete(ctx, release.NewPrefixStore(r.status, r.prefix))
228 if err != nil {
229 return nil, false, err
230 }
231 boxes, complete = root, ok
232 }
233 if r.local != nil {
234 boxes = append(boxes, r.localReport())
235 }
236 for _, s := range []release.Store{r.status, r.deploy} {
237 if s == nil {
238 continue
239 }
240 fb, ok := loadFleetReports(ctx, s)
241 complete = complete && ok
242 boxes = append(boxes, fb...)
243 }
244 return boxes, complete, nil
245}
246
247// localReport is this box's own report, taken now, as the status loop
248// would publish it: BuildStatesErr when the collector fails.
249func (r *boxReports) localReport() status.Box {
250 now := time.Now
251 if r.now != nil {
252 now = r.now
253 }
254 m := &status.BoxMetrics{}
255 if pins, err := r.local(); err != nil {
256 m.BuildStatesErr = err.Error()
257 } else {
258 m.BuildStates = pins
259 }
260 return status.Box{Label: soleReportLabel, GeneratedAt: now(), Metrics: m}
261}
262
263// loadFleetReports reads every fleet's reports in store
264// (fleets/<name>/status/boxes/, enumerated with ListDirs). ok is false when
265// the listing or any report could not be read.
266func loadFleetReports(ctx context.Context, store release.Store) ([]status.Box, bool) {
267 fleets, err := store.ListDirs(ctx, "fleets/")
268 if err != nil {
269 log.Printf("agent: retention: listing fleets: %v", err)
270 return nil, false
271 }
272 var boxes []status.Box
273 ok := true
274 for _, f := range fleets {
275 fb, complete, err := status.LoadBoxesComplete(ctx, release.NewPrefixStore(store, f))
276 if err != nil {
277 log.Printf("agent: retention: reading the reports of %s: %v", f, err)
278 ok = false
279 continue
280 }
281 ok = ok && complete
282 boxes = append(boxes, fb...)
283 }
284 return boxes, ok
285}
286
287// appRecords reads every provision record of acct/app, or fails. It lists
288// the keys itself (provision.ListRecords caps on max and skips a record it
289// cannot read, ruling R-T9-STORE): a key that is a record by ListRecords'
290// own rule (instances/<host>.json, one level) and does not read, parse or
291// agree with its path is an error, so the caller prunes nothing.
292func appRecords(ctx context.Context, store release.Store, acct, app string) ([]provision.Record, error) {
293 prefix := provision.RecordsPrefix(acct, app)
294 keys, err := store.List(ctx, prefix)
295 if err != nil {
296 return nil, fmt.Errorf("listing the records of %s/%s: %w", acct, app, err)
297 }
298 var recs []provision.Record
299 for _, key := range keys {
300 rest, ok := strings.CutPrefix(key, prefix)
301 if !ok || rest == "" || strings.Contains(rest, "/") {
302 continue
303 }
304 host, ok := strings.CutSuffix(rest, ".json")
305 if !ok || host == "" {
306 continue
307 }
308 rec, err := provision.GetRecord(ctx, store, acct, app, host)
309 if err != nil {
310 return nil, fmt.Errorf("reading the record of %s: %w", host, err)
311 }
312 recs = append(recs, *rec)
313 }
314 return recs, nil
315}
316
317// settingsProtected is settings spec rev 11 §4.4's addition to an app's
318// protected set: every pin a provision record names, and every build any
319// box report names for one of the app's instances. It refuses (the caller
320// prunes nothing for the app) when a record is unreadable, when the reports
321// could not all be read, when a fresh report says its box could not collect
322// its build states, when a provisioned instance that keeps a build state is
323// named by no report, or when the newest report naming one of the app's
324// hosts is older than six hours or says that host's build state is unread.
325//
326// The newest report for a host decides freshness and completeness, so a box
327// the instance moved off does not block pruning forever. The builds of
328// every report naming the host are protected, older ones included: that can
329// only keep more.
330func settingsProtected(ctx context.Context, store release.Store, boxes []status.Box, complete bool, now time.Time, acct, app string) (map[string]bool, error) {
331 recs, err := appRecords(ctx, store, acct, app)
332 if err != nil {
333 return nil, err
334 }
335 if !complete {
336 return nil, errors.New("the box reports could not all be read")
337 }
338 out := map[string]bool{}
339 for _, r := range recs {
340 if r.Pin != "" {
341 out[r.Pin] = true
342 }
343 }
344 type newest struct {
345 at time.Time
346 pins status.BuildStatePins
347 box string
348 }
349 byHost := map[string]newest{}
350 for _, b := range boxes {
351 if b.Metrics == nil {
352 continue
353 }
354 // A box that could not collect its build states may host any of
355 // this app's instances (Astra I2 #5).
356 if b.Metrics.BuildStatesErr != "" && now.Sub(b.GeneratedAt) <= buildStateReportMaxAge {
357 return nil, fmt.Errorf("%s could not collect its build states: %s", b.Label, b.Metrics.BuildStatesErr)
358 }
359 for host, p := range b.Metrics.BuildStates {
360 if p.Account != acct || p.App != app {
361 continue
362 }
363 for _, v := range p.Builds {
364 if v != "" {
365 out[v] = true
366 }
367 }
368 if cur, ok := byHost[host]; !ok || b.GeneratedAt.After(cur.at) {
369 byHost[host] = newest{at: b.GeneratedAt, pins: p, box: b.Label}
370 }
371 }
372 }
373 // Every provisioned instance of the app that keeps a build state must
374 // be named by some report (Astra I2 #5): a missing entry is unknown,
375 // never "nothing to protect". For a valid record this is
376 // keepsBuildState's instance rule (activator.ActivatorManaged): an
377 // instance with no unit that hibernates has no addr (the record door
378 // refuses addr with hibernate), so reconcile mints it a socket.
379 for _, r := range recs {
380 if r.KindOrDefault() != "instance" || r.Unit != "" || !r.HibernateOrDefault() {
381 continue
382 }
383 if _, ok := byHost[r.Host]; !ok {
384 return nil, fmt.Errorf("no box report names the build state of %s", r.Host)
385 }
386 }
387 for host, n := range byHost {
388 if now.Sub(n.at) > buildStateReportMaxAge {
389 return nil, fmt.Errorf("%s, which hosts %s, has not reported since %s", n.box, host, n.at.UTC().Format(time.RFC3339))
390 }
391 if !n.pins.Complete {
392 return nil, fmt.Errorf("%s could not read the build state of %s", n.box, host)
393 }
394 }
395 return out, nil
396}
397