carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Prune selection and deletion for shipped releases (issue #136, spec
2// 2026-08-10-releases-retention-design.md §5): which of an app's versions
3// are safe to delete under a keep-newest-N retention policy, and how one
4// version's objects are removed.
5//
6// Everything here is FAIL CLOSED. Any listing, read, or signature error
7// aborts the whole app's answer — the caller skips the app this round.
8// "Prune what we could see" is exactly the bug this posture prevents.
9package release
10
11import (
12 "context"
13 "crypto/ed25519"
14 "errors"
15 "fmt"
16 "os"
17 "strings"
18 "time"
19)
20
21// PruneEligible returns the versions of acct/app that are safe to delete,
22// oldest first. A version is protected — never returned — if it is any of:
23//
24// – among the newest keep versions (manifest-stamp ranking, PR #134's rule)
25// – stamped inside the longest bake hold (HoldFor's fail-closed 72h), so
26// an in-flight promote can never race its own prune
27// – named by any channel pointer: Version or anything in History (a
28// rollback must keep somewhere to go); pointers are signature-verified,
29// and one that does not verify aborts the whole answer
30// – named by any tag's Release field (tags are durable names)
31// – in alsoProtected — the box-side caller passes its own registry's
32// adopted/previous versions; the console passes nil (its preview can
33// only overcount, never undercount, what a box will delete)
34//
35// Pointer AUDIT entries are deliberately NOT protected (ruling in the spec):
36// the audit trail is a log, not a rollback target.
37func PruneEligible(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, keep int, now time.Time, alsoProtected map[string]bool) ([]string, error) {
38 if len(pub) == 0 {
39 return nil, errors.New("prune: no release public key — refusing to select without verifying pointers")
40 }
41 if keep < KeepFloor {
42 return nil, fmt.Errorf("prune: keep %d below floor %d", keep, KeepFloor)
43 }
44 ml, ok := s.(ModLister)
45 if !ok {
46 return nil, errors.New("prune: store cannot rank versions by age — refusing to select")
47 }
48 stamps, err := rankedVersionStamps(ctx, ml, ReleasesPrefix(acct, app))
49 if err != nil {
50 return nil, err
51 }
52 protected := make(map[string]bool, keep+len(alsoProtected))
53 cutoff := now.Add(-HoldFor(DefaultHolds, ""))
54 for i, st := range stamps {
55 if i < keep || st.mod.After(cutoff) {
56 protected[st.version] = true
57 }
58 }
59 chPrefix := ChannelsPrefix(acct, app)
60 chKeys, err := s.List(ctx, chPrefix)
61 if err != nil {
62 return nil, err
63 }
64 for _, k := range chKeys {
65 name, ok := strings.CutSuffix(strings.TrimPrefix(k, chPrefix), ".json")
66 if !ok || name == "" {
67 continue
68 }
69 ptr, err := Channel(ctx, s, pub, acct, app, name)
70 if err != nil {
71 return nil, fmt.Errorf("prune: channel %s: %w", name, err)
72 }
73 protected[ptr.Version] = true
74 for _, v := range ptr.History {
75 protected[v] = true
76 }
77 }
78 tags, err := ListTags(ctx, s, acct, app)
79 if err != nil {
80 return nil, err
81 }
82 for _, t := range tags {
83 protected[t.Release] = true
84 }
85 for v, on := range alsoProtected {
86 if on && v != "" {
87 protected[v] = true
88 }
89 }
90 var eligible []string
91 for i := len(stamps) - 1; i >= 0; i-- {
92 v := stamps[i].version
93 // A name that fails ValidVersion is never turned into a delete key;
94 // skipping it is safe — we simply refuse to touch it.
95 if !protected[v] && ValidVersion(v) {
96 eligible = append(eligible, v)
97 }
98 }
99 return eligible, nil
100}
101
102// DeleteVersion removes one shipped version's objects, MANIFEST FIRST: a
103// half-deleted version then reads as an aborted ship (VersionCandidates'
104// error row), never as a complete version with missing artifacts. Built
105// from idempotent Deletes, so re-running after a partial failure — or
106// racing another box — finishes the job instead of erroring.
107//
108// A concurrent re-ship of the same version name can still interleave here:
109// Ship writes artifacts first and the manifest last, while DeleteVersion
110// deletes the manifest first from a pre-race listing — the window can leave
111// a manifest-present/artifacts-missing state until the next prune tick
112// re-deletes it. Same accepted-TOCTOU family as the spec's promote-vs-prune
113// ruling.
114func claimRawDeleteVersion(ctx context.Context, s Store, acct, app, version string) error {
115 if !ValidAccount(acct) {
116 return fmt.Errorf("delete version: invalid account %q", acct)
117 }
118 if !ValidApp(app) {
119 return fmt.Errorf("delete version: invalid app %q", app)
120 }
121 if !ValidVersion(version) {
122 return fmt.Errorf("delete version: invalid version %q", version)
123 }
124 prefix := ReleasesPrefix(acct, app) + version + "/"
125 keys, err := s.List(ctx, prefix)
126 if err != nil {
127 return err
128 }
129 manifest := ManifestKey(acct, app, version)
130 if err := s.Delete(ctx, manifest); err != nil {
131 return err
132 }
133 for _, k := range keys {
134 if k == manifest || !strings.HasPrefix(k, prefix) {
135 continue
136 }
137 if err := s.Delete(ctx, k); err != nil {
138 return err
139 }
140 }
141 // The version's promotion record goes with it (spec §4.4 keeps only a
142 // protected build's record). Idempotent like every delete here.
143 if err := s.Delete(ctx, PromotionRecordKey(acct, app, version)); err != nil && !errors.Is(err, os.ErrNotExist) {
144 return err
145 }
146 return nil
147}
148func DeleteVersion(ctx context.Context, s Store, acct, app, version string) (claimResult0 error) {
149 claimResult0 = WithAppMutation(ctx, s, acct, app, "release.DeleteVersion", func(claimContext context.Context) error {
150 claimResult0 = claimRawDeleteVersion(claimContext, s, acct, app, version)
151 return claimResult0
152 })
153 return
154}
155