| index ca0c716a1..d41463f7d 100644 |
| --- a/internal/objstore/walk_test.go |
| +++ b/internal/objstore/walk_test.go |
| @@ -844,3 +844,151 @@ func TestRotateUnprovisionedRefused(t *testing.T) { |
| t.Fatalf("refused rotation wrote config: %v", v) |
| } |
| } |
| + |
| +// A rotation changes credentials and NOTHING else. The coordinates it |
| +// redelivers come from the record, so an endpoint-bearing store must come out |
| +// of a rotate still pointing at its endpoint — a rotate that re-derived them |
| +// (or dropped them) would silently move every host onto the backend's own |
| +// address, which is a data-plane outage with a green rotate next to it. |
| +func TestRotateKeepsRecordedCoordinates(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-central-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + d := testDeployment() |
| + d.Endpoint = "https://s3.example.net" |
| + granted, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if granted.Provisioned.Endpoint != d.Endpoint || granted.Provisioned.Region != "eu-central-1" { |
| + t.Fatalf("fixture did not record the coordinates: %+v", granted.Provisioned) |
| + } |
| + |
| + // Rotate takes no Deployment: the only source for these values is the |
| + // record, so this is the assertion that it reads them from there. |
| + rec, err := Rotate(ctx, s, p, pub, "bab", "titogo", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Provisioned.Endpoint != d.Endpoint || rec.Provisioned.Region != "eu-central-1" || |
| + rec.Provisioned.Bucket != granted.Provisioned.Bucket { |
| + t.Fatalf("rotate moved the coordinates: %+v", rec.Provisioned) |
| + } |
| + vars := mustVars(t, s, "bab", "titogo") |
| + if v := vars["TITO_S3_ENDPOINT"]; v.Kind != "env" || v.Value != d.Endpoint { |
| + t.Fatalf("TITO_S3_ENDPOINT after rotate = %+v, want %q", v, d.Endpoint) |
| + } |
| + if v := vars["TITO_S3_REGION"]; v.Kind != "env" || v.Value != "eu-central-1" { |
| + t.Fatalf("TITO_S3_REGION after rotate = %+v", v) |
| + } |
| + if v := vars["TITO_S3_BUCKET"]; v.Kind != "env" || v.Value != granted.Provisioned.Bucket { |
| + t.Fatalf("TITO_S3_BUCKET after rotate = %+v", v) |
| + } |
| + if got := openVar(t, priv, s, "bab", "titogo", "TITO_S3_ACCESS_KEY"); got != rec.Provisioned.KeyID { |
| + t.Fatalf("delivered access key = %q, want the new id %q", got, rec.Provisioned.KeyID) |
| + } |
| + if missing, err := EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", true); err != nil || len(missing) != 0 { |
| + t.Fatalf("after rotate: missing = %v, %v", missing, err) |
| + } |
| +} |
| + |
| +// racingStore lands a competing write on the record immediately BEFORE the |
| +// walk's own Put reaches the store — the interleaving that turns the walk's |
| +// etag stale. One shot, so only the write under test races. |
| +type racingStore struct { |
| + release.Store |
| + raceKeyOnce string |
| + race func(inner release.Store) |
| +} |
| + |
| +func (r *racingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key != "" && key == r.raceKeyOnce { |
| + r.raceKeyOnce = "" |
| + r.race(r.Store) |
| + } |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// The window between a rotate's Get and its Put is real: the console's |
| +// declare/grant doors write the same object. Losing that CAS outright would |
| +// leave the record naming the OLD key while the app runs on the new one — |
| +// and the next rotate's reap would then delete the key actually in use. So |
| +// the walk retries against a re-read record, and the competitor's change to |
| +// the blocks it does not own must survive. |
| +func TestRotateRetriesLostCAS(t *testing.T) { |
| + ctx := context.Background() |
| + priv, pub := testSealKeypair(t) |
| + inner := release.NewDirStore(t.TempDir()) |
| + s := &racingStore{Store: inner} |
| + p := NewMemProvisioner("eu-west-1") |
| + oldID := rotateFixture(t, s, pub, p).Provisioned.KeyID |
| + |
| + // A competing writer touches a block this walk does not own. |
| + s.raceKeyOnce = Key("bab", "titogo") |
| + s.race = func(in release.Store) { |
| + other, etag, err := Get(ctx, in, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other.Declared.By = "someone-else@example.com" |
| + if _, err := Put(ctx, in, "bab", "titogo", other, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + rec, err := Rotate(ctx, s, p, pub, "bab", "titogo", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatalf("rotate lost its CAS and gave up: %v", err) |
| + } |
| + if rec.Provisioned.PrevKeyID != oldID || rec.Provisioned.KeyID == oldID { |
| + t.Fatalf("provisioned = %+v", rec.Provisioned) |
| + } |
| + if rec.Declared.By != "someone-else@example.com" { |
| + t.Fatalf("retry clobbered the competing write: Declared.By = %q", rec.Declared.By) |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored.Declared.By != "someone-else@example.com" || |
| + stored.Provisioned.KeyID != rec.Provisioned.KeyID || stored.Provisioned.PrevKeyID != oldID { |
| + t.Fatalf("stored record = %+v / %+v, %v", stored.Declared, stored.Provisioned, err) |
| + } |
| + // The record and the app agree on the live key — the whole point. |
| + if got := openVar(t, priv, s, "bab", "titogo", "TITO_S3_ACCESS_KEY"); got != stored.Provisioned.KeyID { |
| + t.Fatalf("delivered access key = %q, record says %q", got, stored.Provisioned.KeyID) |
| + } |
| +} |
| + |
| +// Rotate-vs-rotate is the case the retry must NOT paper over: the competitor |
| +// delivered a credential of its own, so overwriting its record would strand |
| +// that key exactly as a lost CAS would strand ours. The conflict is surfaced. |
| +func TestRotateRefusesRetryAgainstAnotherRotation(t *testing.T) { |
| + ctx := context.Background() |
| + _, pub := testSealKeypair(t) |
| + inner := release.NewDirStore(t.TempDir()) |
| + s := &racingStore{Store: inner} |
| + p := NewMemProvisioner("eu-west-1") |
| + oldID := rotateFixture(t, s, pub, p).Provisioned.KeyID |
| + |
| + s.raceKeyOnce = Key("bab", "titogo") |
| + s.race = func(in release.Store) { |
| + other, etag, err := Get(ctx, in, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other.Provisioned.KeyID = "MEMKEY99" // another rotation landed first |
| + other.Provisioned.PrevKeyID = oldID |
| + if _, err := Put(ctx, in, "bab", "titogo", other, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + if _, err := Rotate(ctx, s, p, pub, "bab", "titogo", time.Now().UTC()); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("rotate over a concurrent rotation: %v, want ErrCASConflict", err) |
| + } |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored.Provisioned.KeyID != "MEMKEY99" || stored.Provisioned.PrevKeyID != oldID { |
| + t.Fatalf("the competing rotation was clobbered: %+v, %v", stored.Provisioned, err) |
| + } |
| +} |