objstore: teardown routes crashed grants, guards the final delete
Review fixes. 1. A grant block with no `provisioned` block is NOT "nothing to tear down". Grant creates bucket, user and key and delivers the credentials BEFORE its step-6 record Put, so a lost CAS there leaves exactly that shape over a live, billing, credentialed estate — and the old declared-only fast path deleted the only record naming it. Teardown now routes on the GRANT block: coordinates fall back to Grant.Bucket and UserName(acct, app) (the deterministic name EnsureUser was called with), and only a record with no grant at all dies trivially. 2. The final record Delete was blind. A grant landing between the walk's read and its delete has re-provisioned and recorded a live estate; the delete would erase that record — the same §9 failure from the other end. The delete now re-reads first, Rotate-shaped, and refuses unless the record still names what this walk destroyed (same Provisioned.KeyID, same Grant.Bucket). The refusal wraps release.ErrCASConflict so the console door can say "state changed, re-run" rather than retrying a delete that will never be right. Store.Delete has no CAS, so this narrows the window rather than closing it; documented at the site. Also wraps the two bare error returns with objstore: context. Both fixes are mutation-checked: reverting the routing strands the crashed grant's bucket and key, and defeating the guard erases the competing grant's record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+241
−19
internal/objstore/walk.go+97 −19internal/objstore/walk_test.go+144 −0
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go| index ad7b58929..e2ec5e4b6 100644 |
| --- a/internal/objstore/walk.go |
| +++ b/internal/objstore/walk.go |
| @@ -377,9 +377,16 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app |
| // rather than sweeping the prefix over an estate that is still standing. |
| // |
| // An absent record is nil, not an error: "no store" is the state teardown is |
| -// trying to reach, and both callers race other operators to it. A record with |
| -// no Provisioned block — declared, or granted and never walked — has no |
| -// backend to touch, so it dies trivially. |
| +// trying to reach, and both callers race other operators to it. |
| +// |
| +// What decides the path is the GRANT block, not the provisioned one. A record |
| +// carrying a grant but no `provisioned` is not "nothing to tear down": Grant |
| +// creates the bucket, the user and a key, and delivers the credentials, all |
| +// BEFORE its step-6 Put — so a lost CAS (or any crash) at that last write |
| +// leaves exactly this shape over a live, billing, credentialed estate. Reading |
| +// it as trivially deletable would erase the only record naming that estate, |
| +// which is precisely the failure §9's ordering rule exists to prevent. Only a |
| +// record with NO grant at all — declared, never acted on — dies trivially. |
| func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app string) error { |
| rec, _, err := Get(ctx, s, acct, app) |
| if err != nil { |
| @@ -388,24 +395,50 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app str |
| if rec == nil { |
| return nil |
| } |
| - if rec.Provisioned == nil { |
| - return s.Delete(ctx, Key(acct, app)) |
| - } |
| - pr := rec.Provisioned |
| - // Empty before delete: a real backend refuses to delete a bucket with |
| - // objects in it, and EmptyBucket is paginated and resumable for exactly |
| - // the bucket too big to clear in one call. |
| - if err := p.EmptyBucket(ctx, pr.Bucket); err != nil { |
| - return fmt.Errorf("objstore: empty bucket %s: %w", pr.Bucket, err) |
| - } |
| - if err := p.DeleteBucket(ctx, pr.Bucket); err != nil { |
| - return fmt.Errorf("objstore: delete bucket %s: %w", pr.Bucket, err) |
| + // What this walk believes it is destroying — the guard on the final delete. |
| + startKeyID, startBucket := "", "" |
| + if rec.Provisioned != nil { |
| + startKeyID = rec.Provisioned.KeyID |
| + } |
| + if rec.Grant != nil { |
| + startBucket = rec.Grant.Bucket |
| + } |
| + if rec.Grant == nil && rec.Provisioned == nil { |
| + return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket) |
| + } |
| + // Coordinates: the provisioned block when there is one, since the bucket |
| + // an app's data is in is whatever was recorded at provision time. Falling |
| + // back to the grant covers the crashed-grant shape above — and the user |
| + // name there is not a guess: EnsureUser is called with exactly |
| + // UserName(acct, app), so a principal created by a grant that never |
| + // recorded itself is still findable by name. |
| + bucket, user := "", UserName(acct, app) |
| + switch { |
| + case rec.Provisioned != nil: |
| + bucket, user = rec.Provisioned.Bucket, rec.Provisioned.IAMUser |
| + default: |
| + bucket = rec.Grant.Bucket |
| + } |
| + // A grant block with no bucket cannot come from the walk (Grant resolves |
| + // and validates a name before writing the block), so there is nothing |
| + // nameable to destroy — but the deterministic user might exist, and the |
| + // delivered vars might too, so only the bucket steps are skipped. |
| + if bucket != "" { |
| + // Empty before delete: a real backend refuses to delete a bucket with |
| + // objects in it, and EmptyBucket is paginated and resumable for exactly |
| + // the bucket too big to clear in one call. |
| + if err := p.EmptyBucket(ctx, bucket); err != nil { |
| + return fmt.Errorf("objstore: empty bucket %s: %w", bucket, err) |
| + } |
| + if err := p.DeleteBucket(ctx, bucket); err != nil { |
| + return fmt.Errorf("objstore: delete bucket %s: %w", bucket, err) |
| + } |
| } |
| // DeleteUser reaps the keys and the inline policy, so the credential dies |
| // with the estate it was scoped to — no orphan principal keeps a key that |
| // names a bucket that no longer exists. |
| - if err := p.DeleteUser(ctx, pr.IAMUser); err != nil { |
| - return fmt.Errorf("objstore: delete user %s: %w", pr.IAMUser, err) |
| + if err := p.DeleteUser(ctx, user); err != nil { |
| + return fmt.Errorf("objstore: delete user %s: %w", user, err) |
| } |
| // The delivered set goes even though the credentials behind it are already |
| // dead: leaving them would hand the app's next render a bucket name and a |
| @@ -433,7 +466,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app str |
| // of the names are present, this step is already converged. |
| m, err := appconfig.Get(ctx, s, acct, app) |
| if err != nil { |
| - return err |
| + return fmt.Errorf("objstore: read config for %s/%s: %w", acct, app, err) |
| } |
| present := false |
| for _, name := range unsets { |
| @@ -448,7 +481,52 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app str |
| } |
| } |
| // Last (§9). Everything it named is gone. |
| - return s.Delete(ctx, Key(acct, app)) |
| + return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket) |
| +} |
| + |
| +// deleteRecordIfUnchanged is the final step of Teardown: delete the record, |
| +// but only if it still describes the estate the walk just destroyed. |
| +// |
| +// Store.Delete has no compare-and-swap, so this is the same shape Rotate's |
| +// retry uses — re-read, and act only on a record that still names what we |
| +// started from. The race it closes is a Grant landing mid-teardown: the |
| +// console's grant door writes this same object, and a grant that completes |
| +// after our unset has minted a NEW bucket, user and key and recorded them. A |
| +// blind delete there erases the record of a live estate — the §9 failure, |
| +// arrived at from the other end. |
| +// |
| +// The refusal wraps release.ErrCASConflict so a caller can tell it apart from |
| +// a backend failure: the estate the walk was aimed at IS gone, and the right |
| +// response is to re-run against the record as it now stands, not to retry the |
| +// same delete. A record that has vanished under us is success, not conflict — |
| +// another teardown got there first, which is the state we wanted. |
| +// |
| +// This narrows the window; it does not close it. A grant landing between the |
| +// re-read and the Delete still loses its record, and only a CAS delete in the |
| +// Store interface could fix that. The console door serialises these verbs per |
| +// app, so the remaining window is a fraction of one round trip. |
| +func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, startKeyID, startBucket string) error { |
| + fresh, _, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return fmt.Errorf("objstore: re-read store record for %s/%s: %w", acct, app, err) |
| + } |
| + if fresh == nil { |
| + return nil |
| + } |
| + keyID, bucket := "", "" |
| + if fresh.Provisioned != nil { |
| + keyID = fresh.Provisioned.KeyID |
| + } |
| + if fresh.Grant != nil { |
| + bucket = fresh.Grant.Bucket |
| + } |
| + if keyID != startKeyID || bucket != startBucket { |
| + return fmt.Errorf("objstore: store record for %s/%s changed during teardown — re-run: %w", acct, app, release.ErrCASConflict) |
| + } |
| + if err := s.Delete(ctx, Key(acct, app)); err != nil { |
| + return fmt.Errorf("objstore: delete store record for %s/%s: %w", acct, app, err) |
| + } |
| + return nil |
| } |
| // writeEnv delivers the whole coordinate set in ONE appconfig.Apply — one CAS |
diff --git a/internal/objstore/walk_test.go b/internal/objstore/walk_test.go| index 24f91218d..95f194ca4 100644 |
| --- a/internal/objstore/walk_test.go |
| +++ b/internal/objstore/walk_test.go |
| @@ -1148,3 +1148,147 @@ func TestTeardownConfigObjectAbsent(t *testing.T) { |
| t.Fatalf("teardown minted a config object: %v", err) |
| } |
| } |
| + |
| +// crashedGrant reproduces a grant that created and delivered everything and |
| +// then lost its step-6 record Put: bucket, user, key and delivered vars all |
| +// live, record carrying Declared+Grant and NO provisioned block. |
| +func crashedGrant(t *testing.T, s release.Store, p *MemProvisioner) (bucket, user string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + bucket, user = "carlos-store-tito-bab-titogo", UserName("bab", "titogo") |
| + if _, err := p.EnsureBucket(ctx, bucket); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, _, err := p.MintKey(ctx, user) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // writeEnv had already landed when the record write lost — the credentials |
| + // are in config.json under names no record now explains. |
| + if _, err := appconfig.Apply(ctx, s, "bab", "titogo", map[string]appconfig.Var{ |
| + "TITO_S3_BUCKET": {Kind: "env", Value: bucket}, |
| + "TITO_S3_REGION": {Kind: "env", Value: "eu-west-1"}, |
| + "TITO_S3_ACCESS_KEY": {Kind: "env", Value: id}, |
| + }, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := &Record{ |
| + Declared: &Declared{EnvPrefix: "TITO_S3", At: time.Now().UTC(), By: "member@example.com"}, |
| + Grant: &GrantBlock{Backend: "aws", Bucket: bucket, At: time.Now().UTC(), By: "op"}, |
| + } |
| + if _, err := Put(ctx, s, "bab", "titogo", rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return bucket, user |
| +} |
| + |
| +// A grant block with no provisioned block is NOT "nothing to tear down" — it |
| +// is the shape a crash at Grant's final record write leaves over a live |
| +// estate. Deleting the record alone would strand a billing bucket and a live |
| +// credential nothing names. |
| +func TestTeardownCrashedGrantSweep(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := NewMemProvisioner("eu-west-1") |
| + bucket, _ := crashedGrant(t, s, p) |
| + p.Buckets[bucket]["uploads/one.txt"] = []byte("payload") |
| + |
| + if err := Teardown(ctx, s, p, "bab", "titogo"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(p.Buckets) != 0 || len(p.Users) != 0 { |
| + t.Fatalf("crashed grant left standing: buckets %v users %v", p.Buckets, p.Users) |
| + } |
| + for name := range mustVars(t, s, "bab", "titogo") { |
| + if strings.HasPrefix(name, "TITO_S3_") { |
| + t.Fatalf("delivered var %q survived teardown", name) |
| + } |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored != nil { |
| + t.Fatalf("record after teardown = %+v, %v", stored, err) |
| + } |
| +} |
| + |
| +func TestTeardownCrashedGrantResumable(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := NewMemProvisioner("eu-west-1") |
| + bucket, _ := crashedGrant(t, s, p) |
| + p.Buckets[bucket]["uploads/one.txt"] = []byte("payload") |
| + |
| + p.FailNext["DeleteBucket"] = errors.New("injected: DeleteBucket failed") |
| + if err := Teardown(ctx, s, p, "bab", "titogo"); err == nil { |
| + t.Fatal("DeleteBucket failure: Teardown returned no error") |
| + } |
| + half, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || half == nil || half.Grant == nil || half.Grant.Bucket != bucket { |
| + t.Fatalf("record after DeleteBucket failure = %+v, %v", half, err) |
| + } |
| + |
| + if err := Teardown(ctx, s, p, "bab", "titogo"); err != nil { |
| + t.Fatalf("resume after DeleteBucket failure: %v", err) |
| + } |
| + if len(p.Buckets) != 0 || len(p.Users) != 0 { |
| + t.Fatalf("estate after resume: buckets %v users %v", p.Buckets, p.Users) |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored != nil { |
| + t.Fatalf("record after resume = %+v, %v", stored, err) |
| + } |
| +} |
| + |
| +// raceOnReadStore runs race() the FIRST time onGet is read, before the read |
| +// returns — a competing writer landing at a chosen point in a walk. |
| +type raceOnReadStore struct { |
| + release.Store |
| + onGet string |
| + race func(release.Store) |
| +} |
| + |
| +func (r *raceOnReadStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if r.onGet != "" && key == r.onGet { |
| + r.onGet = "" |
| + r.race(r.Store) |
| + } |
| + return r.Store.Get(ctx, key) |
| +} |
| + |
| +// A grant that lands mid-teardown has re-provisioned a live estate and |
| +// recorded it. The final delete must not erase that record just because this |
| +// walk read an older one. |
| +func TestTeardownRefusesConcurrentRegrant(t *testing.T) { |
| + ctx := context.Background() |
| + inner := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + s := &raceOnReadStore{Store: inner} |
| + teardownFixture(t, s, pub, p) |
| + |
| + // Fire on the config probe — the last read Teardown does before its |
| + // re-read guard, so the competing record is in place by the time the |
| + // guard looks. |
| + s.onGet = appconfig.Key("bab", "titogo") |
| + s.race = func(in release.Store) { |
| + other, etag, err := Get(ctx, in, "bab", "titogo") |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + other.Provisioned.KeyID = "MEMKEY99" // a fresh grant landed and recorded |
| + if _, err := Put(ctx, in, "bab", "titogo", other, etag); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + |
| + if err := Teardown(ctx, s, p, "bab", "titogo"); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("teardown over a concurrent grant: %v, want ErrCASConflict", err) |
| + } |
| + stored, _, err := Get(ctx, inner, "bab", "titogo") |
| + if err != nil || stored == nil || stored.Provisioned == nil || stored.Provisioned.KeyID != "MEMKEY99" { |
| + t.Fatalf("the competing grant's record was erased: %+v, %v", stored, err) |
| + } |
| +} |