carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
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.
22type 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.
33const TrashCooloff = 30 * 24 * time.Hour
34
35// AppTombstoneKey is where an app's trash marker lives in the bucket.
36func 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.
43func 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.
63func 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.
98func 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.
106var 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.
111var 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.
137func 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.
145func 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.
195func 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.
201func 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}
307func 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