| new file mode 100644 |
| index 000000000..010bf1be8 |
| --- /dev/null |
| +++ b/internal/host/seedwiring_test.go |
| @@ -0,0 +1,322 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "os" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// seedRecordFixture writes an account, the shop app and one record of |
| +// shop-1.example.com carrying a seed in state, and returns the record. |
| +func seedRecordFixture(t *testing.T, deploy release.Store, kind, state string) provision.Record { |
| + t.Helper() |
| + ctx := context.Background() |
| + sqid := domains.Sqid(2) |
| + manifest := []byte(`{"id":2,"sqid":"` + sqid + `","name":"Shop","created_at":"2026-08-02T09:00:00Z"}`) |
| + if _, err := deploy.Put(ctx, "accounts/"+sqid+"/account.json", manifest, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.PutManifest(ctx, deploy, sqid, "shop", provision.Manifest{ |
| + Schema: 1, App: "shop", Domain: "example.com", Seed: []string{"seed"}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := provision.Record{Schema: 1, Host: "shop-1.example.com", Slug: "shop-1", App: "shop", Account: sqid, |
| + Channel: "stable", CreatedAt: time.Now().UTC(), |
| + Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, SHA256: "a", Bytes: 2, By: "t", At: time.Now().UTC()}} |
| + if err := provision.CreateRecord(ctx, deploy, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| +func putSnapshot(t *testing.T, data release.Store, host, id string) string { |
| + t.Helper() |
| + k := seeds.SnapshotKey(host, id) |
| + if _, err := data.Put(context.Background(), k, []byte("db"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| +} |
| + |
| +func snapshotPresent(t *testing.T, data release.Store, key string) bool { |
| + t.Helper() |
| + _, _, err := data.Get(context.Background(), key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return true |
| +} |
| + |
| +func seedStateOf(t *testing.T, deploy release.Store, rec provision.Record) string { |
| + t.Helper() |
| + got, err := provision.GetRecord(context.Background(), deploy, rec.Account, rec.App, rec.Host) |
| + if err != nil || got.Seed == nil { |
| + t.Fatalf("record: %+v, %v", got, err) |
| + } |
| + return got.Seed.State |
| +} |
| + |
| +func TestSeedPlacedHookCASesThenDeletesOnlyThisSeed(t *testing.T) { |
| + ctx := context.Background() |
| + deploy, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + rec := seedRecordFixture(t, deploy, seeds.KindReplica, seeds.StateReady) |
| + id := rec.Seed.ID |
| + other, _ := seeds.NewID() |
| + mine, theirs := putSnapshot(t, data, rec.Host, id), putSnapshot(t, data, rec.Host, other) |
| + |
| + hook := seedPlacedHook(deploy, data) |
| + if err := hook(ctx, rec.Account, rec.App, rec.Host, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedStateOf(t, deploy, rec); st != seeds.StatePlaced { |
| + t.Fatalf("state = %s, want placed", st) |
| + } |
| + if snapshotPresent(t, data, mine) { |
| + t.Fatal("this seed's snapshot survived its placement") |
| + } |
| + if !snapshotPresent(t, data, theirs) { |
| + t.Fatal("another seed's snapshot was deleted") |
| + } |
| + |
| + // Another id than the record's: refused, nothing deleted. |
| + if err := hook(ctx, rec.Account, rec.App, rec.Host, other); !errors.Is(err, provision.ErrSeedConflict) { |
| + t.Fatalf("placing another seed's id = %v, want ErrSeedConflict", err) |
| + } |
| + if !snapshotPresent(t, data, theirs) { |
| + t.Fatal("a refused placement deleted a snapshot") |
| + } |
| + |
| + // An id that is not an id: refused before the record or the bucket. |
| + if err := hook(ctx, rec.Account, rec.App, rec.Host, "../x"); err == nil { |
| + t.Fatal("a malformed id was accepted") |
| + } |
| +} |
| + |
| +// The hook runs twice on one finishing wake (Restore's finishPlacement and |
| +// doWake's step 5b), and two boxes can finish one placement. Each call is a |
| +// success, and a call that finds the record already placed with this id |
| +// still deletes the snapshot: the first call's delete may be the one that |
| +// failed. |
| +func TestSeedPlacedHookIsIdempotent(t *testing.T) { |
| + ctx := context.Background() |
| + deploy, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + rec := seedRecordFixture(t, deploy, seeds.KindReplica, seeds.StateReady) |
| + key := putSnapshot(t, data, rec.Host, rec.Seed.ID) |
| + |
| + boxA, boxB := seedPlacedHook(deploy, data), seedPlacedHook(deploy, data) |
| + if err := boxA(ctx, rec.Account, rec.App, rec.Host, rec.Seed.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The first call's delete lost: the snapshot is back, and the record |
| + // already reads placed. The second call deletes it. |
| + putSnapshot(t, data, rec.Host, rec.Seed.ID) |
| + if err := boxA(ctx, rec.Account, rec.App, rec.Host, rec.Seed.ID); err != nil { |
| + t.Fatalf("second call on one box: %v", err) |
| + } |
| + if snapshotPresent(t, data, key) { |
| + t.Fatal("an already-placed record's call left the snapshot") |
| + } |
| + if err := boxB(ctx, rec.Account, rec.App, rec.Host, rec.Seed.ID); err != nil { |
| + t.Fatalf("another box finishing the same placement: %v", err) |
| + } |
| + if st := seedStateOf(t, deploy, rec); st != seeds.StatePlaced { |
| + t.Fatalf("state = %s, want placed", st) |
| + } |
| + |
| + // Two boxes at once, from ready: both succeed, one CAS wins, the |
| + // snapshot goes. |
| + deploy2, data2 := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + rec2 := seedRecordFixture(t, deploy2, seeds.KindReplica, seeds.StateReady) |
| + key2 := putSnapshot(t, data2, rec2.Host, rec2.Seed.ID) |
| + var wg sync.WaitGroup |
| + errs := make([]error, 2) |
| + for i := range errs { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + errs[i] = seedPlacedHook(deploy2, data2)(ctx, rec2.Account, rec2.App, rec2.Host, rec2.Seed.ID) |
| + }() |
| + } |
| + wg.Wait() |
| + for i, err := range errs { |
| + if err != nil { |
| + t.Fatalf("box %d: %v", i, err) |
| + } |
| + } |
| + if st := seedStateOf(t, deploy2, rec2); st != seeds.StatePlaced { |
| + t.Fatalf("state = %s, want placed", st) |
| + } |
| + if snapshotPresent(t, data2, key2) { |
| + t.Fatal("the snapshot survived two finishing boxes") |
| + } |
| +} |
| + |
| +// A refused CAS for any reason but "already placed with this id" deletes |
| +// nothing: the snapshot may be the only copy of the seed. |
| +func TestSeedPlacedHookRefusedDeletesNothing(t *testing.T) { |
| + ctx := context.Background() |
| + for name, tc := range map[string]struct{ kind, state string }{ |
| + "aborted": {seeds.KindReplica, seeds.StateAborted}, |
| + "rolled-back": {seeds.KindReplica, seeds.StateRolledBack}, |
| + "reserving": {seeds.KindReplica, seeds.StateReserving}, |
| + "import, ready": {seeds.KindImport, seeds.StateReady}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + deploy, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + rec := seedRecordFixture(t, deploy, tc.kind, tc.state) |
| + key := putSnapshot(t, data, rec.Host, rec.Seed.ID) |
| + if err := seedPlacedHook(deploy, data)(ctx, rec.Account, rec.App, rec.Host, rec.Seed.ID); !errors.Is(err, provision.ErrSeedConflict) { |
| + t.Fatalf("err = %v, want ErrSeedConflict", err) |
| + } |
| + if !snapshotPresent(t, data, key) { |
| + t.Fatal("a refused placement deleted the snapshot") |
| + } |
| + if st := seedStateOf(t, deploy, rec); st != tc.state { |
| + t.Fatalf("state = %s, want %s unchanged", st, tc.state) |
| + } |
| + }) |
| + } |
| + // No record at all: an error, nothing deleted. |
| + deploy, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + id, _ := seeds.NewID() |
| + key := putSnapshot(t, data, "shop-9.example.com", id) |
| + if err := seedPlacedHook(deploy, data)(ctx, domains.Sqid(2), "shop", "shop-9.example.com", id); err == nil { |
| + t.Fatal("placing a seed with no record succeeded") |
| + } |
| + if !snapshotPresent(t, data, key) { |
| + t.Fatal("a placement with no record deleted the snapshot") |
| + } |
| + // A box that has not wired the stores records nothing. |
| + if err := seedPlacedHook(nil, data)(ctx, domains.Sqid(2), "shop", "shop-9.example.com", id); err == nil { |
| + t.Fatal("an unwired hook reported the seed placed") |
| + } |
| +} |
| + |
| +func TestStoreSeedObjectsOpensTheSnapshotKey(t *testing.T) { |
| + ctx := context.Background() |
| + data := release.NewDirStore(t.TempDir()) |
| + id, _ := seeds.NewID() |
| + if _, err := data.Put(ctx, seeds.SnapshotKey("shop-1.example.com", id), []byte("db-bytes"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rc, err := storeSeedObjects{data}.Open(ctx, "shop-1.example.com", id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := io.ReadAll(rc) |
| + rc.Close() |
| + if string(b) != "db-bytes" { |
| + t.Fatalf("read %q", b) |
| + } |
| + if _, err := (storeSeedObjects{data}).Open(ctx, "shop-1.example.com", "../x"); err == nil { |
| + t.Fatal("a malformed id opened something") |
| + } |
| +} |
| + |
| +func resetArmedSeedHooks(t *testing.T) { |
| + t.Helper() |
| + old := armedSeedHooks.Load() |
| + armedSeedHooks.Store(nil) |
| + t.Cleanup(func() { armedSeedHooks.Store(old) }) |
| +} |
| + |
| +// wireSeedPlacement sets the claim flag whatever happens to the stores, and |
| +// arms the reader, the recorder and the provision pass only together, with |
| +// the data bucket always set on the pass's hooks. |
| +func TestWireSeedPlacement(t *testing.T) { |
| + data, deploy := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + ok := func(s release.Store) func() (release.Store, error) { |
| + return func() (release.Store, error) { return s, nil } |
| + } |
| + fail := func() (release.Store, error) { return nil, errors.New("no credentials") } |
| + |
| + t.Run("armed", func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + eb := &activator.ExecBackend{} |
| + wireSeedPlacement(eb, true, ok(data), ok(deploy)) |
| + if !eb.ClaimArmed || eb.Seeds == nil || eb.OnSeedPlaced == nil { |
| + t.Fatalf("ClaimArmed %v Seeds %v OnSeedPlaced set %v", eb.ClaimArmed, eb.Seeds, eb.OnSeedPlaced != nil) |
| + } |
| + h := armedSeedHooks.Load() |
| + if h == nil || h.Data != release.Store(data) { |
| + t.Fatalf("armed hooks = %+v, want the data bucket", h) |
| + } |
| + }) |
| + t.Run("claims off", func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + eb := &activator.ExecBackend{ClaimArmed: true} |
| + wireSeedPlacement(eb, false, ok(data), ok(deploy)) |
| + if eb.ClaimArmed { |
| + t.Fatal("ClaimArmed set with claim control off") |
| + } |
| + }) |
| + for name, opens := range map[string][2]func() (release.Store, error){ |
| + "no data bucket": {fail, ok(deploy)}, |
| + "no record store": {ok(data), fail}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + eb := &activator.ExecBackend{} |
| + wireSeedPlacement(eb, true, opens[0], opens[1]) |
| + if !eb.ClaimArmed { |
| + t.Fatal("ClaimArmed not set when a store failed") |
| + } |
| + if eb.Seeds != nil || eb.OnSeedPlaced != nil || armedSeedHooks.Load() != nil { |
| + t.Fatal("seed placement half-armed") |
| + } |
| + }) |
| + } |
| + t.Run("hooks without a data bucket are not armed", func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + armSeedHooks(&provision.SeedHooks{}) |
| + if armedSeedHooks.Load() != nil { |
| + t.Fatal("armed SeedHooks with a nil Data") |
| + } |
| + }) |
| +} |
| + |
| +// reconcileSerialized hands the provision pass the armed hooks: with them |
| +// an aborted seed is finished (its snapshot deleted, the record rolled |
| +// back); without them it is left alone. |
| +func TestReconcileSerializedCarriesTheArmedSeedHooks(t *testing.T) { |
| + ctx := context.Background() |
| + deploy, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + rec := seedRecordFixture(t, deploy, seeds.KindReplica, seeds.StateAborted) |
| + key := putSnapshot(t, data, rec.Host, rec.Seed.ID) |
| + |
| + resetArmedSeedHooks(t) |
| + if err := reconcileSerialized(ctx, deploy, restartTestRegistry(t), provision.ReconcileConfig{DataDir: t.TempDir()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedStateOf(t, deploy, rec); st != seeds.StateAborted || !snapshotPresent(t, data, key) { |
| + t.Fatalf("unarmed pass acted on the seed: state %s", st) |
| + } |
| + |
| + armSeedHooks(&provision.SeedHooks{Data: data}) |
| + if err := reconcileSerialized(ctx, deploy, restartTestRegistry(t), provision.ReconcileConfig{DataDir: t.TempDir()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedStateOf(t, deploy, rec); st != seeds.StateRolledBack { |
| + t.Fatalf("state = %s, want rolled-back", st) |
| + } |
| + if snapshotPresent(t, data, key) { |
| + t.Fatal("the aborted seed's snapshot survived an armed pass") |
| + } |
| +} |