feat(console): materialization reaps tombstones and refuses resurrection
MaterializeAccounts now reads every account manifest body — the accepted startup cost — because tombstone state lives in the body and the old "already known, stop here" short-circuit made a deletion performed anywhere else invisible here forever (the Kass gap). A tombstoned manifest reaps its account; a marked row facing a manifest that is live again is logged as corruption and refused, BEFORE anything can reach EnsureAccount. Owner adoption stays birth-only, and birth still means first-ever: reap, rewrite, re-birth would otherwise be a whole-account takeover for anyone holding s3:PutObject and no signing key. MaterializeApps converges app rows the same way, row-driven because a purged app leaves no keys to list: marker → trashed (with the marker's own timestamp, so a late catch-up does not restart the cooloff), no marker + keys → restored, no marker + no keys + already trashed here → row deleted and the name freed. A LIVE row with no keys is left alone — a claimed-but-never-shipped app is indistinguishable from a purged one in the bucket, and only the row's own deleted_at tells them apart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+453
−16
internal/console/materialize.go+184 −16internal/console/materialize_test.go+228 −0internal/console/store.go+41 −0
diff --git a/internal/console/materialize.go b/internal/console/materialize.go| index bf5ca33f1..a655f99dd 100644 |
| --- a/internal/console/materialize.go |
| +++ b/internal/console/materialize.go |
| @@ -51,18 +51,20 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int, |
| if !ok { |
| continue |
| } |
| - // Cheap short-circuit, and the reason a steady-state pass does no |
| - // writes and no object reads at all: the console usually already knows |
| - // every account. |
| - // |
| - // This is ALSO the security boundary for the owner field, so it must |
| - // stay an unconditional "already known, stop here". See materializeOwner |
| - // for why an account the console has seen before is never revisited. |
| - if a, err := st.AccountBySqid(sqid); err != nil { |
| + // The UNFILTERED lookup, deliberately: this pass is the one place in |
| + // the console that must tell "never seen" from "seen, and deleted". |
| + // Every other door asks AccountBySqid and gets nothing for a marked |
| + // row; here, a marked row is the console's MEMORY of a tombstone, and |
| + // forgetting it is a takeover (see the switch below). |
| + a, deleted, err := st.AccountAnyBySqid(sqid) |
| + if err != nil { |
| return n, err |
| - } else if a != nil { |
| - continue |
| } |
| + // Read UNCONDITIONALLY now, including for accounts we already know. |
| + // This is the accepted cost of reaping: tombstone state lives in the |
| + // manifest BODY, so the old "known, stop here before the GET" |
| + // short-circuit would make a deletion performed elsewhere invisible |
| + // here forever. One small GET per account, at startup. |
| body, _, err := s.Get(ctx, key) |
| if err != nil { |
| log.Printf("console: skipping %s: read failed: %v", key, err) |
| @@ -78,14 +80,59 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int, |
| "not a new account", key, sqid, m.Sqid, m.ID, domains.Sqid(m.ID)) |
| continue |
| } |
| - // A retired account stays retired. Without this, deletion would be a |
| - // lie that lasts until the next console start: this pass would find the |
| - // manifest, see no local row, and re-create the account together with |
| - // the owner membership the manifest names — which after an ownership |
| - // transfer is not even the person who deleted it. |
| - if m.Deleted() { |
| + // Everything the row and the manifest can say to each other. Only the |
| + // last case — never seen here, and live — falls through to birth. |
| + switch { |
| + case deleted && m.Deleted(): |
| + continue // both sides agree the account is retired; nothing to do |
| + |
| + case deleted && !m.Deleted(): |
| + // A marked row is tombstone MEMORY, and memory does not argue with |
| + // a rewritten manifest. Re-materializing here — let alone |
| + // re-adopting the owner the manifest now names — would hand the |
| + // account, its sqid, its platform hostnames and its whole apps |
| + // prefix to whoever holds bucket write access, with no signing key |
| + // involved. The manifest is not write-once (see materializeOwner), |
| + // so "reap, rewrite, re-birth" would be a complete account |
| + // takeover available to anyone who can PUT one object. |
| + // |
| + // An account id is spent once. This is corruption, not an |
| + // un-deletion, and it is logged as such. |
| + log.Printf("console: manifest %s is live again but the account was deleted here — "+ |
| + "refusing to resurrect it; an account id is spent once", key) |
| + continue |
| + |
| + case a != nil && m.Deleted(): |
| + // The control plane has ruled; converge on it. This is what makes a |
| + // deletion performed anywhere — another box, `carlos accounts |
| + // delete`, an operator with only bucket access — become real here |
| + // without hand-run SQL (the Kass gap). |
| + if err := st.ReapAccount(a.ID, time.Now()); err != nil { |
| + log.Printf("console: reap deleted account %s: %v", sqid, err) |
| + } else { |
| + log.Printf("console: reaped account %s (%s) — its manifest is tombstoned", sqid, a.Name) |
| + } |
| + continue |
| + |
| + case a != nil: |
| + // Known and live: settled. INSERT-only, and — the security |
| + // boundary — owner adoption is never revisited for an account the |
| + // console has seen before. |
| + continue |
| + |
| + case m.Deleted(): |
| + // Never seen here and already retired: it stays unseen. Without |
| + // this, deletion would be a lie that lasts until the next console |
| + // start on a fresh box — the manifest would be read, no row found, |
| + // and the account re-created together with the owner membership it |
| + // names, which after an ownership transfer is not even the person |
| + // who deleted it. |
| continue |
| } |
| + |
| + // FIRST-EVER materialization from here down. Nothing above may reach |
| + // EnsureAccount or materializeOwner: birth happens once per account id, |
| + // for as long as this database lives. |
| created := m.CreatedAt |
| if created.IsZero() { |
| created = time.Now() |
| @@ -198,6 +245,122 @@ func materializeOwner(st *Store, key string, m accountManifest, created time.Tim |
| } |
| } |
| +// MaterializeApps converges the console's app rows onto the bucket, which is |
| +// the control plane for trash state exactly as it is for account identity. |
| +// |
| +// Trash and purge both happen where this console is not: `carlos` running |
| +// headless on a box, a second console, the auto-purge pass sweeping cooled-off |
| +// markers. Each writes the bucket; none opens this database. So the console |
| +// catches up the same way it catches up on accounts — by reading the objects. |
| +// |
| +// Four cases, decided per row: |
| +// |
| +// (a) live row + valid marker → mark it trashed (trashed elsewhere) |
| +// (b) marked row + no marker + keys → un-mark it (restored elsewhere) |
| +// (c) marked row + no marker + no keys → DELETE the row (purged elsewhere); |
| +// the row IS the name reservation, so this is where the name frees |
| +// (d) live row + no marker + no keys → NOTHING. |
| +// |
| +// Case (d) is the guard the whole design turns on: an app claimed but never |
| +// shipped has no objects, which makes it byte-for-byte indistinguishable from |
| +// a purged one in the bucket. The only thing that tells them apart is the row's |
| +// own deleted_at — so convergence may only ever destroy a row that was ALREADY |
| +// trashed here. PurgeAppRow enforces the same rule in SQL. |
| +// |
| +// Row-driven, not bucket-driven: a purged app leaves no keys to list, so the |
| +// listing alone can never raise case (c). Keys under an apps/<sqid>/ prefix |
| +// whose sqid this console does not know contribute nothing to anything — |
| +// another deployment's tenants are not this console's business. |
| +// |
| +// Best-effort like the rest of this file: a nil store or a failed listing is |
| +// "nothing to catch up on", never a reason to stop the console. |
| +func MaterializeApps(ctx context.Context, s release.Store, st *Store) error { |
| + if s == nil { |
| + return nil |
| + } |
| + rows, err := st.AppRows() |
| + if err != nil { |
| + return err |
| + } |
| + if len(rows) == 0 { |
| + return nil |
| + } |
| + keys, err := s.List(ctx, "apps/") |
| + if err != nil { |
| + return err |
| + } |
| + // One listing, indexed by app prefix. The prefixes come from the ROWS, so |
| + // an unknown account's keys simply never find a home here. |
| + type bucketState struct{ anyKeys, marker bool } |
| + state := make(map[string]*bucketState, len(rows)) |
| + for _, r := range rows { |
| + state["apps/"+r.Sqid+"/"+r.App+"/"] = &bucketState{} |
| + } |
| + for _, key := range keys { |
| + parts := strings.SplitN(key, "/", 4) |
| + if len(parts) < 4 || parts[0] != "apps" { |
| + continue // not an app-scoped key: apps/<sqid>/<app>/<something> |
| + } |
| + bs := state["apps/"+parts[1]+"/"+parts[2]+"/"] |
| + if bs == nil { |
| + continue |
| + } |
| + // Any object under the prefix counts, including a bare directory |
| + // placeholder: convergence fails toward KEEPING the row. |
| + bs.anyKeys = true |
| + if key == release.AppTombstoneKey(parts[1], parts[2]) { |
| + bs.marker = true |
| + } |
| + } |
| + for _, r := range rows { |
| + bs := state["apps/"+r.Sqid+"/"+r.App+"/"] |
| + switch { |
| + case bs.marker: |
| + // Re-read rather than trusting the listing: it is one GET, and a |
| + // marker is an instruction to hide an app. ReadAppTombstone |
| + // VALIDATES — a body that disagrees with its path, or carries no |
| + // timestamp, is not something to act on either way. |
| + tomb, err := release.ReadAppTombstone(ctx, s, r.Sqid, r.App) |
| + if err != nil { |
| + log.Printf("console: leaving %s/%s alone: %v", r.Sqid, r.App, err) |
| + continue |
| + } |
| + if tomb == nil || r.Deleted { |
| + continue // restored between the listing and the read, or already agreed |
| + } |
| + // The clock comes from the MARKER. A console catching up a week |
| + // later must not restart the 30-day cooloff. |
| + if err := st.TrashAppRow(r.AccountID, r.App, tomb.DeletedAt); err != nil { |
| + log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err) |
| + continue |
| + } |
| + log.Printf("console: %s/%s is in the trash in the bucket — marking it trashed here", r.Sqid, r.App) |
| + |
| + case !r.Deleted: |
| + continue // (d), and the ordinary live app: no marker, nothing to do |
| + |
| + case bs.anyKeys: |
| + if err := st.RestoreAppRow(r.AccountID, r.App); err != nil { |
| + log.Printf("console: restore %s/%s: %v", r.Sqid, r.App, err) |
| + continue |
| + } |
| + log.Printf("console: %s/%s has no trash marker and still has objects — restoring it here", r.Sqid, r.App) |
| + |
| + default: |
| + // Purged: marker gone, objects gone, and the row was already |
| + // trashed here — so this frees the name rather than destroying |
| + // anything. PurgeAppRow is guarded to refuse a live row even if |
| + // this reasoning is ever wrong. |
| + if err := st.PurgeAppRow(r.AccountID, r.App); err != nil { |
| + log.Printf("console: purge %s/%s: %v", r.Sqid, r.App, err) |
| + continue |
| + } |
| + log.Printf("console: %s/%s is gone from the bucket — releasing the name here", r.Sqid, r.App) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| // materializeQuietly is the call site every entry point actually wants: catch |
| // up if we can, log and carry on if we cannot. Separated from |
| // MaterializeAccounts so the failure policy lives in ONE place rather than |
| @@ -206,4 +369,9 @@ func materializeQuietly(ctx context.Context, s release.Store, st *Store) { |
| if _, err := MaterializeAccounts(ctx, s, st); err != nil { |
| log.Printf("console: could not materialize accounts from the bucket: %v", err) |
| } |
| + // After accounts, always: a reaped account's app rows are gone with it, so |
| + // this pass then has nothing to say about them. |
| + if err := MaterializeApps(ctx, s, st); err != nil { |
| + log.Printf("console: could not converge app rows with the bucket: %v", err) |
| + } |
| } |
diff --git a/internal/console/materialize_test.go b/internal/console/materialize_test.go| index 1206fbc76..845d9b3ff 100644 |
| --- a/internal/console/materialize_test.go |
| +++ b/internal/console/materialize_test.go |
| @@ -383,6 +383,229 @@ func TestMaterializeLeavesAnOwnerlessManifestOwnerless(t *testing.T) { |
| } |
| } |
| +// THE KASS GAP, closed: a deletion performed anywhere becomes real here. |
| +// |
| +// Before this, MaterializeAccounts merely SKIPPED a tombstoned manifest, so an |
| +// account deleted on another box (or by `carlos accounts delete`) kept its |
| +// console row, its memberships and its apps forever — the console said the |
| +// account was live, the control plane said it was gone, and only hand-run SQL |
| +// could settle it. The bucket is the control plane: it has already ruled. |
| +func TestMaterializeReapsTombstonedAccounts(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + now := time.Unix(1700000000, 0).UTC() |
| + |
| + id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 1 { |
| + t.Fatalf("materialized %d (%v), want the new account", n, err) |
| + } |
| + if _, err := st.CreateAppIn(id, "blog", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.SetLastAccount("paul@keymail.dev", id); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Deleted somewhere else entirely, while this console knew nothing about it. |
| + if err := domains.TombstoneAccount(ctx, rel, sqid, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| + t.Fatalf("materialized %d (%v) on the reaping pass, want 0 — a reap is not a birth", n, err) |
| + } |
| + |
| + if a, err := st.AccountBySqid(sqid); err != nil || a != nil { |
| + t.Errorf("AccountBySqid after the tombstone = %+v %v, want nothing: the control plane has ruled", a, err) |
| + } |
| + // Invisible, but REMEMBERED — see the resurrection test for what forgetting costs. |
| + if a, deleted, err := st.AccountAnyBySqid(sqid); err != nil || a == nil || !deleted { |
| + t.Fatalf("AccountAnyBySqid = %+v %v %v, want a marked row", a, deleted, err) |
| + } |
| + if members, _ := st.Members(id); len(members) != 0 { |
| + t.Errorf("Members = %+v, want none — membership against a dead account is access to nothing", members) |
| + } |
| + if apps, _ := st.AppsIn(id); len(apps) != 0 { |
| + t.Errorf("AppsIn = %v, want none", apps) |
| + } |
| + if trashed, _ := st.TrashedAppsIn(id); len(trashed) != 0 { |
| + t.Errorf("TrashedAppsIn = %+v, want none — the app rows are reaped with the account", trashed) |
| + } |
| + if last, err := st.LastAccount("paul@keymail.dev"); err != nil || last != 0 { |
| + t.Errorf("LastAccount = %d %v, want 0 — the MRU must not land anyone in a reaped account", last, err) |
| + } |
| + // Stable: a second pass finds a marked row and a tombstoned manifest agreeing. |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| + t.Errorf("second pass materialized %d (%v), want 0", n, err) |
| + } |
| +} |
| + |
| +// THE SECURITY TEST. Owner adoption is birth-only, and "birth" must mean |
| +// FIRST-EVER — not "first since the row went away". |
| +// |
| +// Deleting the row would forget the account was ever seen, and forgetting |
| +// re-arms the whole attack materializeOwner exists to prevent: tombstone an |
| +// account (or wait for someone to delete one), let the console reap it, then |
| +// rewrite the manifest un-tombstoned with yourself as owner. A console that |
| +// treats that as a first materialization re-births the account — same id, same |
| +// sqid, same platform hostnames, same apps prefix — and adopts the attacker. |
| +// Bucket write access, no signing key, a whole account. |
| +// |
| +// So a marked row is tombstone MEMORY, and memory does not argue with a |
| +// rewritten manifest. An account id is spent once. |
| +func TestMaterializeRefusesResurrection(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + now := time.Unix(1700000000, 0).UTC() |
| + |
| + id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 1 { |
| + t.Fatalf("materialized %d (%v), want the new account", n, err) |
| + } |
| + if err := domains.TombstoneAccount(ctx, rel, sqid, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := MaterializeAccounts(ctx, rel, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, deleted, _ := st.AccountAnyBySqid(sqid); !deleted { |
| + t.Fatal("the account was not reaped, so this test is not testing what it says") |
| + } |
| + |
| + // Somebody with plain s3:PutObject — no CARLOS_RELEASE_KEY, no console |
| + // session — writes the manifest back, live, naming themselves. |
| + rewriteManifest(t, rel, id, "eve@evil.example") |
| + |
| + // Every console start runs this pass, and so does every `console member`. |
| + for i := 0; i < 2; i++ { |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| + t.Fatalf("pass %d materialized %d (%v) — a spent account id was re-birthed from a rewritten manifest", i, n, err) |
| + } |
| + } |
| + if a, _ := st.AccountBySqid(sqid); a != nil { |
| + t.Errorf("the account came back as %+v — reap, rewrite, re-birth is an account takeover", a) |
| + } |
| + if _, deleted, _ := st.AccountAnyBySqid(sqid); !deleted { |
| + t.Error("the marked row lost its mark — the console has forgotten the tombstone") |
| + } |
| + if role, _ := st.Role(id, "eve@evil.example"); role != "" { |
| + t.Errorf("the rewritten manifest granted %q — bucket write became console authority", role) |
| + } |
| + if accts, _ := st.AccountsFor("eve@evil.example"); len(accts) != 0 { |
| + t.Errorf("AccountsFor(attacker) = %+v, want nothing adopted", accts) |
| + } |
| + if members, _ := st.Members(id); len(members) != 0 { |
| + t.Errorf("Members = %+v, want none", members) |
| + } |
| +} |
| + |
| +// App-row convergence: the console's rows follow the bucket, because trash and |
| +// purge can both happen while this console is down (headless `carlos`, another |
| +// box, the auto-purge pass). Four cases, and case (d) is the one that keeps |
| +// convergence honest. |
| +func TestMaterializeAppsConverges(t *testing.T) { |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + now := time.Unix(1700000000, 0).UTC() |
| + trashedAt := now.Add(-48 * time.Hour) |
| + |
| + id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 1 { |
| + t.Fatalf("materialized %d (%v), want the account", n, err) |
| + } |
| + for _, app := range []string{"alpha", "beta", "gamma", "delta"} { |
| + if _, err := st.CreateAppIn(id, app, "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + shipped := func(app string) { |
| + t.Helper() |
| + if _, err := rel.Put(ctx, "apps/"+sqid+"/"+app+"/channels/stable.json", []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // (a) live row, trashed headlessly: the marker exists in the bucket. |
| + shipped("alpha") |
| + if err := release.TrashApp(ctx, rel, sqid, "alpha", "paul@keymail.dev", trashedAt); err != nil { |
| + t.Fatal(err) |
| + } |
| + // (b) marked row, restored headlessly: marker gone, objects still there. |
| + shipped("beta") |
| + if err := st.TrashAppRow(id, "beta", trashedAt); err != nil { |
| + t.Fatal(err) |
| + } |
| + // (c) marked row, purged: no marker and no objects at all. |
| + if err := st.TrashAppRow(id, "gamma", trashedAt); err != nil { |
| + t.Fatal(err) |
| + } |
| + // (d) live row, never shipped: no marker, no objects — a legitimate claim. |
| + // Nothing about it is distinguishable from (c) except that its row is LIVE. |
| + |
| + // Another account's keys, for a sqid this console has never heard of: they |
| + // must contribute nothing to anybody's decision. |
| + if _, err := rel.Put(ctx, "apps/"+domains.Sqid(999999)+"/alpha/channels/stable.json", []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := MaterializeApps(ctx, rel, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if live, _ := st.HasAppIn(id, "alpha"); live { |
| + t.Error("(a) alpha has a trash marker in the bucket but its row is still live") |
| + } |
| + if trashed, _ := st.HasTrashedAppIn(id, "alpha"); !trashed { |
| + t.Error("(a) alpha was not marked trashed from its bucket marker") |
| + } |
| + if live, _ := st.HasAppIn(id, "beta"); !live { |
| + t.Error("(b) beta's marker is gone and its objects remain — the row should be live again") |
| + } |
| + if trashed, _ := st.HasTrashedAppIn(id, "gamma"); trashed { |
| + t.Error("(c) gamma is purged in the bucket but its row still holds the name") |
| + } |
| + if live, _ := st.HasAppIn(id, "gamma"); live { |
| + t.Error("(c) gamma came back to life") |
| + } |
| + if accts, _ := st.AppAccounts("gamma"); len(accts) != 0 { |
| + t.Errorf("(c) gamma still has rows %v — the row IS the name reservation, so purge must free it", accts) |
| + } |
| + if live, _ := st.HasAppIn(id, "delta"); !live { |
| + t.Error("(d) a claimed-but-never-shipped app was eaten by convergence — " + |
| + "an app with no objects is indistinguishable from a purged one except by its own row") |
| + } |
| + |
| + // The cooloff clock comes from the MARKER, not from this pass: the bucket |
| + // records when the app was trashed, and a console catching up days later |
| + // must not restart the 30 days. |
| + trashedRows, err := st.TrashedAppsIn(id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(trashedRows) != 1 || trashedRows[0].App != "alpha" || !trashedRows[0].DeletedAt.Equal(trashedAt) { |
| + t.Errorf("TrashedAppsIn = %+v, want alpha trashed at %v", trashedRows, trashedAt) |
| + } |
| + |
| + // Idempotent: convergence has converged. |
| + if err := MaterializeApps(ctx, rel, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + apps, _ := st.AppsIn(id) |
| + if len(apps) != 2 || apps[0] != "beta" || apps[1] != "delta" { |
| + t.Errorf("AppsIn after a second pass = %v, want [beta delta]", apps) |
| + } |
| +} |
| + |
| // rewriteManifest overwrites a live account manifest with one naming owner — |
| // what a bucket-writer does, and the thing MintAccount's create-only Put does |
| // NOT prevent anyone else from doing. The etag is read and passed because the |
| @@ -415,4 +638,9 @@ func TestMaterializeWithNoStoreIsANoOp(t *testing.T) { |
| if n, err := MaterializeAccounts(t.Context(), nil, st); err != nil || n != 0 { |
| t.Errorf("MaterializeAccounts(nil store) = %d %v, want 0 nil", n, err) |
| } |
| + // And app convergence above all: with no bucket to compare against, "no |
| + // keys" would otherwise read as "purged everywhere". |
| + if err := MaterializeApps(t.Context(), nil, st); err != nil { |
| + t.Errorf("MaterializeApps(nil store) = %v, want nil", err) |
| + } |
| } |
diff --git a/internal/console/store.go b/internal/console/store.go| index d3585aac0..e6d0fc23a 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -1140,6 +1140,47 @@ func (s *Store) HasTrashedAppIn(accountID int64, app string) (bool, error) { |
| return n > 0, err |
| } |
| +// AppRow is one apps row carrying its account's SQID — the coordinate the |
| +// bucket is keyed by. The console's own tables key apps by account id, but |
| +// every object belonging to an app lives under apps/<sqid>/<app>/, so a pass |
| +// that compares rows against the control plane needs both. |
| +type AppRow struct { |
| + AccountID int64 |
| + Sqid string |
| + App string |
| + Deleted bool // trashed here (deleted_at != 0), not purged |
| +} |
| + |
| +// AppRows lists every app row of every LIVE account, trashed ones included — |
| +// the input to MaterializeApps, which is the one caller. |
| +// |
| +// It has to be row-driven rather than bucket-driven: a purged app has no keys |
| +// left to list, so the only evidence it ever existed is the row still holding |
| +// its name. The join drops rows belonging to a marked account, which ReapAccount |
| +// already deletes — a straggler must never be converged against a dead |
| +// account's prefix, and freeing its name is that account's business, not this |
| +// pass's. |
| +func (s *Store) AppRows() ([]AppRow, error) { |
| + rows, err := s.db.Query(`SELECT p.account_id, a.sqid, p.app, p.deleted_at |
| +FROM apps p JOIN accounts a ON a.id = p.account_id |
| +WHERE a.deleted_at = 0 ORDER BY a.sqid, p.app`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []AppRow |
| + for rows.Next() { |
| + var r AppRow |
| + var deletedAt int64 |
| + if err := rows.Scan(&r.AccountID, &r.Sqid, &r.App, &deletedAt); err != nil { |
| + return nil, err |
| + } |
| + r.Deleted = deletedAt != 0 |
| + out = append(out, r) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // AppAccounts lists the accounts that claim an app NAME, ascending. Usually |
| // one; several is legitimate (names are unique per account, not globally) and |
| // is what makes MoveApp require an explicit source. |