| new file mode 100644 |
| index 000000000..aae4a6342 |
| --- /dev/null |
| +++ b/internal/console/seedfinish_test.go |
| @@ -0,0 +1,440 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// finishFixture is the --from fixture with private storage on and one data |
| +// bucket, as production has it: seeds/ and every instance's replica in the |
| +// same store, so the leftover-replica rail and the finish see one bucket. |
| +func finishFixture(t *testing.T) fromFixture { |
| + t.Helper() |
| + f := newFromFixture(t, 3) |
| + f.data = release.NewDirStore(f.root) |
| + f.fx.srv.cfg.SeedData = f.data |
| + enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| + return f |
| +} |
| + |
| +// rollBack walks a seed the way a box does once it has been aborted: the |
| +// abort, then the box's CAS to rolled-back. |
| +func (f fromFixture) rollBack(t *testing.T, host, id string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + for _, step := range []func(*provision.Record) error{provision.SeedAbort(id), provision.SeedRolledBack(id)} { |
| + if _, err := provision.SetRecordSeed(ctx, f.fx.rel, "bab", fromApp, host, step); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// seedRecord writes a record of shop on host in env whose seed is id, of |
| +// kind, in state. |
| +func (f fromFixture) seedRecord(t *testing.T, host, env, id, kind, state string) { |
| + t.Helper() |
| + rec := provision.Record{Schema: 1, Host: host, Slug: "canary", App: fromApp, Account: "bab", Channel: "stable", |
| + Environment: env, CreatedAt: time.Now().UTC(), |
| + Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, By: "bob@keymail.dev", At: time.Now().UTC()}} |
| + if err := provision.CreateRecord(context.Background(), f.fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// markedStore writes a canary store holding a grant, made by seed ("" |
| +// for a store no seed made). |
| +func (f fromFixture) markedStore(t *testing.T, seed string) { |
| + t.Helper() |
| + at := time.Now().UTC().Add(-time.Hour) |
| + putCanaryStoreOf(t, f.fx, fromApp, &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "bob@keymail.dev", Seed: seed}, |
| + Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-shop-canary", Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"}, |
| + }) |
| +} |
| + |
| +func (f fromFixture) canaryStore(t *testing.T) *objstore.Record { |
| + t.Helper() |
| + rec, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "canary") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| +func (f fromFixture) rawKey(t *testing.T, s release.Store, key string) string { |
| + t.Helper() |
| + b, _, err := s.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", key, err) |
| + } |
| + return string(b) |
| +} |
| + |
| +func (f fromFixture) requireRecordGone(t *testing.T, host string) { |
| + t.Helper() |
| + if rec, err := f.record(t, host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("record %s after the finish = %+v, %v; want it gone", host, rec, err) |
| + } |
| +} |
| + |
| +func newSeedID(t *testing.T) string { |
| + t.Helper() |
| + id, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return id |
| +} |
| + |
| +func replicaKeys(t *testing.T, f fromFixture, host string) []string { |
| + t.Helper() |
| + keys, err := f.data.List(context.Background(), "instances/"+host+"/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return keys |
| +} |
| + |
| +// The whole finish for a --from seed that failed after its first upload: |
| +// its own store torn down for real, its snapshot and its replica deleted, |
| +// and the record last. The host is then free for the same copy again: no |
| +// leftover store for §4 to refuse, no replica for the #199 rail to refuse. |
| +func TestFinishSeedTearsDownAndFreesTheHost(t *testing.T) { |
| + f := finishFixture(t) |
| + rec := f.copy(t, fromBody) |
| + requireReady(t, rec) |
| + id := rec.Seed.ID |
| + store := f.canaryStore(t) |
| + if store == nil || store.Declared == nil || store.Declared.Seed != id { |
| + t.Fatalf("canary store = %+v; want the copy's own", store) |
| + } |
| + bucket := storeBucketName(store) |
| + if _, ok := f.fake.Buckets[bucket]; !ok { |
| + t.Fatalf("bucket %s was never made", bucket) |
| + } |
| + // A box placed the copy and uploaded its first replica before the |
| + // abort landed (a lost publish answer, Plan 5 T5). |
| + dbdoortest.Replicate(t, filepath.Join(f.root, "instances", fromHost), 2) |
| + if held, err := f.fx.srv.hostHoldsReplicaData(context.Background(), fromHost); err != nil || !held { |
| + t.Fatalf("replica before the finish: %v, %v", held, err) |
| + } |
| + sourceReplica := replicaKeys(t, f, fromSource) |
| + f.rollBack(t, fromHost, id) |
| + |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := f.canaryStore(t); got != nil { |
| + t.Fatalf("store record after the finish = %+v; want it gone", got) |
| + } |
| + if _, ok := f.fake.Buckets[bucket]; ok { |
| + t.Fatalf("bucket %s survived the teardown", bucket) |
| + } |
| + if _, _, err := f.data.Get(context.Background(), seeds.SnapshotKey(fromHost, id)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("snapshot after the finish: %v; want it gone", err) |
| + } |
| + if keys := replicaKeys(t, f, fromHost); len(keys) != 0 { |
| + t.Fatalf("replica after the finish: %v", keys) |
| + } |
| + if held, err := f.fx.srv.hostHoldsReplicaData(context.Background(), fromHost); err != nil || held { |
| + t.Fatalf("the rail after the finish: %v, %v", held, err) |
| + } |
| + if got := replicaKeys(t, f, fromSource); !slices.Equal(got, sourceReplica) { |
| + t.Fatalf("the source's replica changed:\n before %v\n after %v", sourceReplica, got) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| + |
| + // Idempotent: a second finish (the sweep after an inline one) is done. |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatalf("a second finish: %v", err) |
| + } |
| + again := f.copy(t, fromBody) |
| + requireReady(t, again) |
| + if again.Seed.ID == id { |
| + t.Fatal("the new copy reused the old seed id") |
| + } |
| + if store := f.canaryStore(t); store == nil || store.Declared == nil || store.Declared.Seed != again.Seed.ID { |
| + t.Fatalf("the new copy's store = %+v; want one marked with %s", store, again.Seed.ID) |
| + } |
| +} |
| + |
| +// A teardown that fails leaves the record rolled-back and holding the host, |
| +// so the next finish (the sweep's) resumes and completes. |
| +func TestFinishSeedResumesAfterAFailedTeardown(t *testing.T) { |
| + f := finishFixture(t) |
| + rec := f.copy(t, fromBody) |
| + requireReady(t, rec) |
| + id := rec.Seed.ID |
| + f.rollBack(t, fromHost, id) |
| + f.fake.FailNext["DeleteBucket"] = errors.New("the backend is down") |
| + |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("a failed teardown answered nil") |
| + } |
| + if got, err := f.record(t, fromHost); err != nil || got.Seed == nil || got.Seed.State != seeds.StateRolledBack { |
| + t.Fatalf("record after a failed teardown = %+v, %v; want it kept, rolled-back", got, err) |
| + } |
| + if f.canaryStore(t) == nil { |
| + t.Fatal("the store record went before its estate") |
| + } |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatalf("the resumed finish: %v", err) |
| + } |
| + if got := f.canaryStore(t); got != nil { |
| + t.Fatalf("store record after the resumed finish = %+v", got) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// Only a store this seed made is torn down. Any other store is left as it |
| +// was, and the record is still deleted. |
| +func TestFinishSeedLeavesAStoreItDidNotMake(t *testing.T) { |
| + for name, seed := range map[string]string{ |
| + "another seed's": "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb", |
| + "no seed's": "", |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, seed) |
| + key := objstore.KeyIn("bab", fromApp, "canary") |
| + before := f.rawKey(t, f.fx.rel, key) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := f.rawKey(t, f.fx.rel, key); after != before { |
| + t.Fatalf("the store record changed:\n before %s\n after %s", before, after) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| + }) |
| + } |
| +} |
| + |
| +// A record the finish does not own is left alone, with the store its seed |
| +// made: another seed, a seed not yet rolled back, no seed at all. |
| +func TestFinishSeedLeavesARecordItDoesNotOwn(t *testing.T) { |
| + id := "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| + for name, c := range map[string]struct{ id, state string }{ |
| + "another seed": {"sd_bbbbbbbbbbbbbbbbbbbbbbbbbb", seeds.StateRolledBack}, |
| + "aborted, not yet": {id, seeds.StateAborted}, |
| + "ready": {id, seeds.StateReady}, |
| + "placed": {id, seeds.StatePlaced}, |
| + "seedless (no seed)": {"", ""}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := finishFixture(t) |
| + if c.id == "" { |
| + if err := provision.CreateRecord(context.Background(), f.fx.rel, provision.Record{Schema: 1, Host: fromHost, Slug: "canary", |
| + App: fromApp, Account: "bab", Channel: "stable", Environment: "canary", CreatedAt: time.Now().UTC()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + f.seedRecord(t, fromHost, "canary", c.id, seeds.KindReplica, c.state) |
| + } |
| + f.markedStore(t, id) |
| + if _, err := f.data.Put(context.Background(), "instances/"+fromHost+"/0000/0000000000000001-0000000000000001.ltx", []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + recKey := provision.RecordKey("bab", fromApp, fromHost) |
| + storeKey := objstore.KeyIn("bab", fromApp, "canary") |
| + recBefore, storeBefore := f.rawKey(t, f.fx.rel, recKey), f.rawKey(t, f.fx.rel, storeKey) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := f.rawKey(t, f.fx.rel, recKey); after != recBefore { |
| + t.Fatalf("the record changed:\n before %s\n after %s", recBefore, after) |
| + } |
| + if after := f.rawKey(t, f.fx.rel, storeKey); after != storeBefore { |
| + t.Fatalf("the store record changed:\n before %s\n after %s", storeBefore, after) |
| + } |
| + if keys := replicaKeys(t, f, fromHost); len(keys) != 1 { |
| + t.Fatalf("replica = %v; want it untouched", keys) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The finish decides under the store's walk lock (preflight M9). A grant |
| +// by another writer that held the lock while the finish queued, and took |
| +// the store over (clearing its seed mark), wins: the finish re-reads under |
| +// the lock and leaves the store standing. |
| +func TestFinishSeedReReadsTheStoreUnderItsLock(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, id) |
| + unlock := f.fx.srv.storeWalks.lock("bab", fromApp, "canary") |
| + done := make(chan error, 1) |
| + go func() { done <- f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id) }() |
| + deadline := time.Now().Add(3 * time.Second) |
| + for { |
| + f.fx.srv.storeWalks.mu.Lock() |
| + refs := 0 |
| + if e := f.fx.srv.storeWalks.m[storeWalkKey("bab", fromApp, "canary")]; e != nil { |
| + refs = e.refs |
| + } |
| + f.fx.srv.storeWalks.mu.Unlock() |
| + if refs == 2 { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + unlock() |
| + t.Fatalf("the finish never queued on the store lock: refs=%d", refs) |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| + store, etag, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "canary") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + store.Declared.Seed = "" |
| + if _, err := objstore.PutIn(context.Background(), f.fx.rel, "bab", fromApp, "canary", store, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + key := objstore.KeyIn("bab", fromApp, "canary") |
| + taken := f.rawKey(t, f.fx.rel, key) |
| + unlock() |
| + if err := <-done; err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := f.rawKey(t, f.fx.rel, key); after != taken { |
| + t.Fatalf("a store taken over under the lock was touched:\n before %s\n after %s", taken, after) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// Defence in depth (preflight M10): a store this seed made is not torn down |
| +// while another instance names its environment. The record still goes: |
| +// the store now serves the other instance, and an operator decides. |
| +func TestFinishSeedSparesAStoreAnotherInstanceUses(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + if err := provision.CreateRecord(context.Background(), f.fx.rel, provision.Record{Schema: 1, Host: "other.shop.example.com", Slug: "other", |
| + App: fromApp, Account: "bab", Channel: "stable", Environment: "canary", CreatedAt: time.Now().UTC()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.markedStore(t, id) |
| + key := objstore.KeyIn("bab", fromApp, "canary") |
| + before := f.rawKey(t, f.fx.rel, key) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := f.rawKey(t, f.fx.rel, key); after != before { |
| + t.Fatalf("a shared store was touched:\n before %s\n after %s", before, after) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// failingRecordList fails every listing of the app's records. |
| +type failingRecordList struct { |
| + release.Store |
| + prefix string |
| +} |
| + |
| +func (s failingRecordList) List(ctx context.Context, prefix string) ([]string, error) { |
| + if prefix == s.prefix { |
| + return nil, errors.New("the bucket is unreachable") |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +// When the records cannot be listed the finish cannot tell whether the |
| +// store is shared, so it does nothing and answers an error for the sweep |
| +// to retry. |
| +func TestFinishSeedRetriesWhenTheRecordsCannotBeListed(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, id) |
| + rel := f.fx.srv.cfg.Releases |
| + f.fx.srv.cfg.Releases = failingRecordList{Store: rel, prefix: provision.RecordsPrefix("bab", fromApp)} |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("a failed listing answered nil") |
| + } |
| + if f.canaryStore(t) == nil { |
| + t.Fatal("the store record went on a failed listing") |
| + } |
| + if _, err := f.record(t, fromHost); err != nil { |
| + t.Fatalf("the record went on a failed listing: %v", err) |
| + } |
| +} |
| + |
| +// A store this seed made with no backend to tear it down with is an error, |
| +// and the record keeps holding the host. |
| +func TestFinishSeedNeedsAStorageBackend(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + f.markedStore(t, id) |
| + f.fx.srv.cfg.StoreProvisioner = nil |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err == nil { |
| + t.Fatal("no backend answered nil") |
| + } |
| + if _, err := f.record(t, fromHost); err != nil { |
| + t.Fatalf("the record went with its store still standing: %v", err) |
| + } |
| +} |
| + |
| +// An import's finish deletes its snapshot and never the host's replica: |
| +// only a --from seed's replica is known to be its own. |
| +func TestFinishSeedLeavesAnImportsReplica(t *testing.T) { |
| + f := finishFixture(t) |
| + id := newSeedID(t) |
| + f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| + ctx := context.Background() |
| + if _, err := f.data.Put(ctx, seeds.SnapshotKey(fromHost, id), []byte("snapshot"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + dbdoortest.Replicate(t, filepath.Join(f.root, "instances", fromHost), 2) |
| + before := replicaKeys(t, f, fromHost) |
| + if err := f.fx.srv.finishSeed(ctx, "bab", fromApp, fromHost, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := f.data.Get(ctx, seeds.SnapshotKey(fromHost, id)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("snapshot after the finish: %v; want it gone", err) |
| + } |
| + if after := replicaKeys(t, f, fromHost); len(before) == 0 || !slices.Equal(after, before) { |
| + t.Fatalf("an import's finish touched the replica:\n before %v\n after %v", before, after) |
| + } |
| + f.requireRecordGone(t, fromHost) |
| +} |
| + |
| +// A seed id that fails seeds.Check builds no key and deletes nothing |
| +// (preflight M8): every bad id would map to the one shared sentinel. |
| +func TestFinishSeedChecksTheSeedBeforeAnyKey(t *testing.T) { |
| + f := finishFixture(t) |
| + ctx := context.Background() |
| + sentinel := seeds.SnapshotKey(fromHost, "sd_bad") |
| + if _, err := f.data.Put(ctx, sentinel, []byte("someone else's"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []struct{ host, id string }{{fromHost, "sd_bad"}, {"../shop", newSeedID(t)}} { |
| + if err := f.fx.srv.finishSeed(ctx, "bab", fromApp, c.host, c.id); !errors.Is(err, seeds.ErrBadID) && !errors.Is(err, seeds.ErrBadHost) { |
| + t.Fatalf("finishSeed(%q, %q) = %v; want a seeds.Check error", c.host, c.id, err) |
| + } |
| + } |
| + if _, _, err := f.data.Get(ctx, sentinel); err != nil { |
| + t.Fatalf("the sentinel key was deleted: %v", err) |
| + } |
| +} |
| + |
| +// No record: nothing to finish, and no error. |
| +func TestFinishSeedWithNoRecord(t *testing.T) { |
| + f := finishFixture(t) |
| + if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, newSeedID(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |