| 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. |
| 9 | package release |
| 10 | |
| 11 | import ( |
| 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. |
| 37 | func 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. |
| 114 | func 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 | } |
| 148 | func 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 | |