| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | // AppTombstone is the wire shape at apps/<sqid>/<app>/deleted.json — the |
| 14 | // trash marker. Its PRESENCE means the app is in the Trash: consumers drop |
| 15 | // its routes and hide it; the 30-day clock (DeletedAt + TrashCooloff) runs |
| 16 | // bucket-side. There is no box-side memory to ratchet: restore deletes the |
| 17 | // marker outright, so a marker that exists IS the current state. |
| 18 | // |
| 19 | // ADDITIVE by design: nothing written before this type changes shape, and a |
| 20 | // consumer that has never heard of deleted.json keeps serving the app until |
| 21 | // it is upgraded — visible and harmless, per the wire-additive rule. |
| 22 | type AppTombstone struct { |
| 23 | Schema int `json:"schema"` |
| 24 | Account string `json:"account"` |
| 25 | App string `json:"app"` |
| 26 | DeletedAt time.Time `json:"deleted_at"` |
| 27 | Actor string `json:"actor"` |
| 28 | } |
| 29 | |
| 30 | // TrashCooloff is how long a trashed app stays restorable before the |
| 31 | // auto-purge pass may destroy it. A constant, deliberately: a deployment |
| 32 | // that wants a different number edits it here. |
| 33 | const TrashCooloff = 30 * 24 * time.Hour |
| 34 | |
| 35 | // AppTombstoneKey is where an app's trash marker lives in the bucket. |
| 36 | func AppTombstoneKey(acct, app string) string { |
| 37 | return "apps/" + acct + "/" + app + "/deleted.json" |
| 38 | } |
| 39 | |
| 40 | // TrashApp writes the trash marker, create-only. Trashing an app that is |
| 41 | // already in the trash is a no-op that does NOT move the cooloff clock or |
| 42 | // rewrite the actor — the first trash is the one on record. |
| 43 | func claimRawTrashApp(ctx context.Context, s Store, acct, app, actor string, now time.Time) error { |
| 44 | body, err := json.Marshal(AppTombstone{ |
| 45 | Schema: 1, Account: acct, App: app, DeletedAt: now.UTC(), Actor: actor, |
| 46 | }) |
| 47 | if err != nil { |
| 48 | return err |
| 49 | } |
| 50 | if _, err := s.Put(ctx, AppTombstoneKey(acct, app), body, ""); err != nil { |
| 51 | if errors.Is(err, ErrCASConflict) { |
| 52 | return nil // already trashed; the existing marker stands |
| 53 | } |
| 54 | return fmt.Errorf("release: write trash marker for %s/%s: %w", acct, app, err) |
| 55 | } |
| 56 | return nil |
| 57 | } |
| 58 | |
| 59 | // ReadAppTombstone reads and VALIDATES the marker. (nil, nil) when absent. |
| 60 | // Anything else short of a well-formed marker whose body agrees with its |
| 61 | // path and carries a real timestamp is an ERROR — a caller about to destroy |
| 62 | // or hide an app must never act on a marker it cannot fully believe. |
| 63 | func ReadAppTombstone(ctx context.Context, s Store, acct, app string) (*AppTombstone, error) { |
| 64 | body, _, err := s.Get(ctx, AppTombstoneKey(acct, app)) |
| 65 | if errors.Is(err, os.ErrNotExist) { |
| 66 | return nil, nil |
| 67 | } |
| 68 | if err != nil { |
| 69 | return nil, fmt.Errorf("release: read trash marker for %s/%s: %w", acct, app, err) |
| 70 | } |
| 71 | var t AppTombstone |
| 72 | if err := json.Unmarshal(body, &t); err != nil { |
| 73 | return nil, fmt.Errorf("release: malformed trash marker for %s/%s: %w", acct, app, err) |
| 74 | } |
| 75 | // The schema is PINNED, and an unknown one refuses. A future schema 2 |
| 76 | // could mean anything — a scoped marker, a partial deletion, an expiry |
| 77 | // this box has never heard of — and the two things a marker drives are |
| 78 | // hiding an app and destroying it. Refusing fails additive-safe, the way |
| 79 | // the whole wire rule wants: an old box keeps serving and keeps the |
| 80 | // bytes, loudly, until it is upgraded. Believing a shape we cannot read |
| 81 | // would be the one direction that is not recoverable. |
| 82 | if t.Schema != 1 { |
| 83 | return nil, fmt.Errorf("release: trash marker for %s/%s has schema %d — this box only understands schema 1; refusing to act on it", |
| 84 | acct, app, t.Schema) |
| 85 | } |
| 86 | if t.Account != acct || t.App != app { |
| 87 | return nil, fmt.Errorf("release: trash marker at %s claims %s/%s — path and body disagree", |
| 88 | AppTombstoneKey(acct, app), t.Account, t.App) |
| 89 | } |
| 90 | if t.DeletedAt.IsZero() { |
| 91 | return nil, fmt.Errorf("release: trash marker for %s/%s has no deleted_at", acct, app) |
| 92 | } |
| 93 | return &t, nil |
| 94 | } |
| 95 | |
| 96 | // RestoreApp deletes the marker. Store.Delete is idempotent by contract, so |
| 97 | // restoring an app that is not in the trash is a no-op, not an error. |
| 98 | func RestoreApp(ctx context.Context, s Store, acct, app string) error { |
| 99 | return s.Delete(ctx, AppTombstoneKey(acct, app)) |
| 100 | } |
| 101 | |
| 102 | // ErrNotTrashed is PurgeApp's refusal to destroy an app that has no valid |
| 103 | // trash marker: purge without an instruction is not a purge, it is a bug. |
| 104 | // Distinguishable on purpose — a caller sweeping the trash reads it as |
| 105 | // "already done", not as a failure worth alarming about. |
| 106 | var ErrNotTrashed = errors.New("release: app is not in the trash") |
| 107 | |
| 108 | // ErrStoreProvisioned is PurgeApp's refusal to destroy an app whose object |
| 109 | // store has (or may have) backend estate behind it — see the guard in |
| 110 | // PurgeApp for why a box is the wrong actor to decide otherwise. |
| 111 | var ErrStoreProvisioned = errors.New("release: app has a provisioned object store — tear it down via the console before purging") |
| 112 | |
| 113 | // PurgeReady answers "would PurgeApp destroy this app right now?" — the |
| 114 | // preconditions and nothing else: a valid trash marker, a release public key to |
| 115 | // prove production with, and a production flag that is readable and off. It is |
| 116 | // PurgeApp's OWN precondition block, extracted so a caller that must act BEFORE |
| 117 | // PurgeApp can ask the same question from the same list. |
| 118 | // |
| 119 | // The caller it exists for is the console's purge door (console/ownership.go), |
| 120 | // which tears an object store down before re-entering PurgeApp. That teardown |
| 121 | // destroys a bucket, an IAM principal and the app's delivered credentials — all |
| 122 | // of it outside the deployment bucket, and none of it undoable — so it must not |
| 123 | // happen in front of a purge that PurgeApp then refuses: a restore that landed |
| 124 | // while the form sat open, a production flag set after trashing, an unreadable |
| 125 | // flag, a console with no public key. Asking here first keeps those refusals |
| 126 | // exactly as strong as they read in PurgeApp's doc comment. |
| 127 | // |
| 128 | // The store guard and the sweep are deliberately NOT here: the store guard is |
| 129 | // the box's refusal to act without backend authority (the console door's whole |
| 130 | // purpose is to have already dealt with it), and the sweep is the destruction |
| 131 | // itself. |
| 132 | // |
| 133 | // This narrows the window; it does not close it. PurgeApp re-checks all of it |
| 134 | // immediately before destroying, so the residual race is the one round trip |
| 135 | // between the two calls — the design's stated tolerance, and the same shape |
| 136 | // objstore.Teardown's final re-read settles for. |
| 137 | func PurgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) error { |
| 138 | return purgeReady(ctx, s, pub, acct, app, nil) |
| 139 | } |
| 140 | |
| 141 | // purgeReady is PurgeReady with an optional extra precondition on the marker |
| 142 | // it has just read. check sees the SAME tombstone the rest of this block |
| 143 | // trusts, so a caller binding a purge to one particular trash |
| 144 | // (PurgeAppIfTrashedAt) adds no second read. |
| 145 | func purgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error { |
| 146 | tomb, err := ReadAppTombstone(ctx, s, acct, app) |
| 147 | if err != nil { |
| 148 | return err |
| 149 | } |
| 150 | if tomb == nil { |
| 151 | return ErrNotTrashed |
| 152 | } |
| 153 | if check != nil { |
| 154 | if err := check(tomb); err != nil { |
| 155 | return err |
| 156 | } |
| 157 | } |
| 158 | if len(pub) != ed25519.PublicKeySize { |
| 159 | return fmt.Errorf("release: no release public key — cannot prove %s/%s is not in production, refusing to purge", acct, app) |
| 160 | } |
| 161 | // (nil, nil) here means the object is genuinely absent, which is the |
| 162 | // default and not production. Every other outcome — unreadable, |
| 163 | // unverifiable, misnamed — arrives as an error and refuses, because an |
| 164 | // unreadable flag is exactly the state an attacker who can delete but not |
| 165 | // sign can create. |
| 166 | flag, err := GetProduction(ctx, s, pub, acct, app) |
| 167 | if err != nil { |
| 168 | return fmt.Errorf("release: cannot read the production flag for %s/%s — refusing to purge: %w", acct, app, err) |
| 169 | } |
| 170 | if flag != nil && flag.Production { |
| 171 | return fmt.Errorf("release: %s/%s is flagged production — a trashed production app should be impossible; refusing to purge", acct, app) |
| 172 | } |
| 173 | return nil |
| 174 | } |
| 175 | |
| 176 | // PurgeApp irreversibly destroys a trashed app's bucket objects: every key |
| 177 | // under apps/<acct>/<app>/ and ledgers/<acct>/<app>/, the trash marker LAST, |
| 178 | // so a purge that dies half-way leaves a trashed app with fewer objects |
| 179 | // rather than a live app with holes — the next pass finishes the job. |
| 180 | // |
| 181 | // This is the platform's first destructive automation surface, so every |
| 182 | // guard fails toward keeping data: |
| 183 | // - no valid marker → ErrNotTrashed (re-read HERE, immediately before |
| 184 | // destruction — a restore that landed after the caller's listing wins); |
| 185 | // - nil pub, production flag ON, or production flag UNREADABLE → refuse. |
| 186 | // Flag ABSENT is fine: most apps never had one, and GetProduction says so |
| 187 | // with (nil, nil). |
| 188 | // |
| 189 | // The AGE of the marker is deliberately the caller's decision, not this |
| 190 | // function's: the auto-purge pass enforces TrashCooloff, and the console's |
| 191 | // "Delete now" purges early behind its own ceremony. now is therefore taken |
| 192 | // and not read — it is here so that every caller passes its OWN clock seam |
| 193 | // and no future guard added inside this function has to reach for |
| 194 | // time.Now() behind the tests' backs. |
| 195 | func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error { |
| 196 | return purgeApp(ctx, s, pub, acct, app, nil) |
| 197 | } |
| 198 | |
| 199 | // purgeApp is PurgeApp's body with purgeReady's optional check threaded |
| 200 | // through. Everything below the precondition call is PurgeApp as it was. |
| 201 | func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error { |
| 202 | // The preconditions live in purgeReady so that the console's purge door, |
| 203 | // which must satisfy them BEFORE it tears an object store down, checks the |
| 204 | // same list this function does. One list, one place: a guard added there is |
| 205 | // a guard both callers get, and neither can drift from the other. |
| 206 | if err := purgeReady(ctx, s, pub, acct, app, check); err != nil { |
| 207 | return err |
| 208 | } |
| 209 | prefix := appPrefix(acct, app) |
| 210 | // An object store with backend estate refuses the purge outright (objstore |
| 211 | // spec §9): auto-purge runs on BOXES, boxes hold no backend authority, and |
| 212 | // a swept store.json is a paid bucket and a live IAM user that nothing |
| 213 | // remembers. The console's purge door tears the store down first and only |
| 214 | // then re-enters here. |
| 215 | // |
| 216 | // BOTH blocks count. `provisioned` is the finished store; `grant` on its |
| 217 | // own is a grant that crashed after creating the bucket and the user but |
| 218 | // before recording them — the earliest bucket-visible witness that estate |
| 219 | // exists. Refusing on either keeps the box on the safe side of a crash. |
| 220 | // |
| 221 | // Parsed minimally IN PLACE: release cannot import objstore (objstore |
| 222 | // imports release), so this file is on store.json's parser list, |
| 223 | // deliberately — the consumer list for a storage shape is everything that |
| 224 | // PARSES it, not everything that calls its API. objstore.Record carries |
| 225 | // the matching note; a block renamed there without changing this struct |
| 226 | // silently disarms the guard. objstore/walk_test.go pins the agreement. |
| 227 | // |
| 228 | // Unreadable — transport error or bytes that will not parse — refuses too, |
| 229 | // the same direction as the production flag: "I could not ask" is not "no |
| 230 | // store". Absent is fine; most apps never declare one. |
| 231 | // EVERY store of the app, not just store.json. Since 2026-09-19 a store |
| 232 | // may belong to a named config environment, at |
| 233 | // apps/<acct>/<app>/stores/<env>.json, and each one names backend estate |
| 234 | // exactly as the app's own does. A guard that read store.json alone would |
| 235 | // let a box sweep an app whose environment stores are live — this guard's |
| 236 | // own failure, reached through the door the newer shape opened. |
| 237 | // |
| 238 | // The listing is part of the guard, so a listing that FAILS refuses the |
| 239 | // purge, the same direction as an unreadable record: "I could not ask" is |
| 240 | // not "no store". List returns nested keys under a prefix in both store |
| 241 | // implementations (DirStore walks the root; S3Store paginates with no |
| 242 | // delimiter), which is what makes one listing enough. |
| 243 | storeKeys := []string{prefix + "store.json"} |
| 244 | envStores, lerr := s.List(ctx, prefix+"stores/") |
| 245 | if lerr != nil { |
| 246 | return fmt.Errorf("release: %s/%s: cannot list %sstores/ — refusing to purge: %w: %v", |
| 247 | acct, app, prefix, ErrStoreProvisioned, lerr) |
| 248 | } |
| 249 | storeKeys = append(storeKeys, envStores...) |
| 250 | for _, storeKey := range storeKeys { |
| 251 | raw, _, err := s.Get(ctx, storeKey) |
| 252 | if err != nil { |
| 253 | if errors.Is(err, os.ErrNotExist) { |
| 254 | continue // absent is fine; most apps never declare one |
| 255 | } |
| 256 | return fmt.Errorf("release: %s/%s: cannot read %s — refusing to purge: %w: %v", |
| 257 | acct, app, storeKey, ErrStoreProvisioned, err) |
| 258 | } |
| 259 | var rec struct { |
| 260 | Grant *json.RawMessage `json:"grant"` |
| 261 | Provisioned *json.RawMessage `json:"provisioned"` |
| 262 | } |
| 263 | if jerr := json.Unmarshal(raw, &rec); jerr != nil { |
| 264 | return fmt.Errorf("release: %s/%s: unparseable %s — refusing to purge: %w: %v", |
| 265 | acct, app, storeKey, ErrStoreProvisioned, jerr) |
| 266 | } |
| 267 | if rec.Provisioned != nil || rec.Grant != nil { |
| 268 | return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned) |
| 269 | } |
| 270 | } |
| 271 | marker := AppTombstoneKey(acct, app) |
| 272 | keys, err := s.List(ctx, prefix) |
| 273 | if err != nil { |
| 274 | return fmt.Errorf("release: list %s: %w", prefix, err) |
| 275 | } |
| 276 | // The app's open ledgers too (discussion #219, item 2). They live |
| 277 | // outside the app's prefix, keyed by the same (account, app), so a sweep |
| 278 | // of apps/ alone left them for the next app of this name: its ledger |
| 279 | // writes would chain onto the purged app's history, and the edge would |
| 280 | // keep serving that history if the old manifest said public. Swept |
| 281 | // before the marker, so a purge that dies here is retried by the next |
| 282 | // pass. The ledger spec's "publication is forever" holds as far as it |
| 283 | // ever did: the bucket is versioned, and that is where it is kept. |
| 284 | ledgerKeys, err := s.List(ctx, LedgerAppPrefix(acct, app)) |
| 285 | if err != nil { |
| 286 | return fmt.Errorf("release: list %s: %w", LedgerAppPrefix(acct, app), err) |
| 287 | } |
| 288 | keys = append(keys, ledgerKeys...) |
| 289 | // The app's promotion records (settings spec §4.1) live outside its |
| 290 | // prefix too, for the same reason as the ledgers: a new app of this name |
| 291 | // must not find them. |
| 292 | promoted, err := s.List(ctx, PromotedPrefix(acct, app)) |
| 293 | if err != nil { |
| 294 | return fmt.Errorf("release: list %s: %w", PromotedPrefix(acct, app), err) |
| 295 | } |
| 296 | keys = append(keys, promoted...) |
| 297 | for _, key := range keys { |
| 298 | if key == marker { |
| 299 | continue // last, below |
| 300 | } |
| 301 | if err := s.Delete(ctx, key); err != nil { |
| 302 | return fmt.Errorf("release: purge %s: %w", key, err) |
| 303 | } |
| 304 | } |
| 305 | return s.Delete(ctx, marker) |
| 306 | } |
| 307 | func TrashApp(ctx context.Context, s Store, acct, app, actor string, now time.Time) (claimResult0 error) { |
| 308 | claimResult0 = WithAppMutation(ctx, s, acct, app, "release.TrashApp", func(claimContext context.Context) error { |
| 309 | claimResult0 = claimRawTrashApp(claimContext, s, acct, app, actor, now) |
| 310 | return claimResult0 |
| 311 | }) |
| 312 | return |
| 313 | } |
| 314 | |