carlos / platform Public

host: wire seed placement into the activator and the provision pass (Plan 4 Task 6)

ExecBackend.Seeds, OnSeedPlaced and ClaimArmed are set in production
(wireSeedPlacement); reconcileSerialized hands the provision pass the
armed SeedHooks, never one without a data bucket. seedPlacedHook CASes
the record to placed before deleting this seed's snapshot; a record
already placed with this id still deletes, any other refusal deletes
nothing. A compile-time guard pins execLease's Rep to *lease.Replicator
with SyncAndWait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9207b1c2390e29938f6396c8c84d53a7fc92d95e parent 7bd503a
5 files changed, +518 −0
  • internal/activator/backend_exec.go +8 −0
  • internal/activator/seedsplit_test.go +49 −0
  • internal/host/agent.go +11 −0
  • internal/host/seedwiring.go +128 −0
  • internal/host/seedwiring_test.go +322 −0

CI passed — run details

diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 832feed1d..c58d647ab 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -621,6 +621,14 @@ func (l *execLease) Replicate(ctx context.Context, dbPath string, syncInterval t
return rep, nil
}
+// The Rep execLease.Replicate hands back is lease.Held's *lease.Replicator,
+// and SeedPlaced records a seed placed only through its SyncAndWait. Pinned
+// here so a change to either fails the build, not every seeded wake.
+var (
+ _ func(*lease.Held, context.Context, string, time.Duration) (*lease.Replicator, error) = (*lease.Held).Replicate
+ _ interface{ SyncAndWait(context.Context) error } = (*lease.Replicator)(nil)
+)
+
func (l *execLease) Release(ctx context.Context) error { return l.held.Release(ctx) }
// Restore puts the row's database in place for a wake. A row whose seed is
diff --git a/internal/activator/seedsplit_test.go b/internal/activator/seedsplit_test.go
new file mode 100644
index 000000000..4f4d6d627
--- /dev/null
+++ b/internal/activator/seedsplit_test.go
@@ -0,0 +1,49 @@
+package activator
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// seedPlacingBackend is a fakeBackend that records SeedPlaced calls.
+type seedPlacingBackend struct {
+ *fakeBackend
+ placed []string
+ rep Rep
+}
+
+func (b *seedPlacingBackend) SeedPlaced(_ context.Context, rt registry.Route, rep Rep) error {
+ b.placed = append(b.placed, rt.Host)
+ b.rep = rep
+ return nil
+}
+
+type noSyncRep struct{}
+
+func (noSyncRep) Err() error { return nil }
+func (noSyncRep) Close(context.Context) error { return nil }
+
+// SplitBackend.SeedPlaced dispatches on Backing: an exec route reaches the
+// exec backend's SeedPlaced with the same Rep; a unit route is never
+// forwarded and answers nil.
+func TestSplitBackendSeedPlacedDispatchesByBacking(t *testing.T) {
+ eb := &seedPlacingBackend{fakeBackend: &fakeBackend{}}
+ sb := &SplitBackend{Exec: eb, Unit: &UnitBackend{OptDir: t.TempDir()}}
+ ctx := context.Background()
+ rep := noSyncRep{}
+
+ if err := sb.SeedPlaced(ctx, testUnitRoute("unit.example.com"), rep); err != nil {
+ t.Fatalf("unit route: %v", err)
+ }
+ if len(eb.placed) != 0 {
+ t.Fatalf("a unit route reached the exec backend: %q", eb.placed)
+ }
+ if err := sb.SeedPlaced(ctx, testRoute("shop-1.example.com"), rep); err != nil {
+ t.Fatalf("exec route: %v", err)
+ }
+ if len(eb.placed) != 1 || eb.placed[0] != "shop-1.example.com" || eb.rep != Rep(rep) {
+ t.Fatalf("exec route forwarded %q with rep %v", eb.placed, eb.rep)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 533e2a307..a9959a147 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -407,6 +407,12 @@ func runAgent(args []string, buildClaims ClaimFactory) {
} else {
eb.Mirror = m
}
+ // Seeded instances (seeded-instances spec §3.4): the seed reader,
+ // the placed recorder, the provision pass's hooks and the claim
+ // flag the seed fallback refuses under. Before Start, like the
+ // mirror.
+ wireSeedPlacement(eb, claims.Writes.Enabled, seedDataOpener(cfg.Bucket, cfg.Region),
+ func() (release.Store, error) { return openDeployStore(context.Background()) })
armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes)
armRunsPass(svc, reg, dd)
// The startup sweep (design §6.7): a leftover instance unit from a
@@ -1142,6 +1148,11 @@ func reconcileSerialized(ctx context.Context, s release.Store, reg *registry.Reg
return mintLayoutIntentWith(ctx, s, reg.List)
}
}
+ // Seeded records (seeded-instances spec §3.2): acted on only once the
+ // activator has armed the data bucket (wireSeedPlacement).
+ if cfg.Seeds == nil {
+ cfg.Seeds = armedSeedHooks.Load()
+ }
// Move holds (moves spec §9.6): participant rows minted held and kept.
// Unchanged with the Phase 1 switch off.
cfg = withMovesHolds(cfg)
diff --git a/internal/host/seedwiring.go b/internal/host/seedwiring.go
new file mode 100644
index 000000000..f4dca6af7
--- /dev/null
+++ b/internal/host/seedwiring.go
@@ -0,0 +1,128 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// storeSeedObjects reads seed snapshots from this box's data bucket.
+type storeSeedObjects struct{ s release.Store }
+
+// Open streams seeds/<host>/<id>/snapshot.db. A store that cannot stream is
+// refused rather than read whole: a seed can be gigabytes.
+func (o storeSeedObjects) Open(ctx context.Context, host, id string) (io.ReadCloser, error) {
+ if err := seeds.Check(host, id); err != nil {
+ return nil, fmt.Errorf("seed %q of %s: %w", id, host, err)
+ }
+ op, ok := o.s.(release.Opener)
+ if !ok {
+ return nil, errors.New("the data bucket store cannot stream")
+ }
+ rc, _, err := op.Open(ctx, seeds.SnapshotKey(host, id))
+ return rc, err
+}
+
+// seedPlacedHook is ExecBackend.OnSeedPlaced (seeded-instances spec §3.4
+// step 5): the record's CAS to placed, then the delete of this seed's
+// snapshot, never another's. The activator calls it only once the host's
+// own replica holds the seed.
+//
+// It is called more than once for one placement: twice on a finishing wake
+// (Restore's finishPlacement and doWake's step 5b), and by any box that
+// wakes the host before the first call's delete lands. A record already
+// placed (or committed) with this id is that case: a success, and the
+// delete still runs, because the earlier call's delete may be the one that
+// failed. A CAS refused for any other reason deletes nothing.
+func seedPlacedHook(deploy, data release.Store) func(ctx context.Context, acct, app, host, id string) error {
+ return func(ctx context.Context, acct, app, host, id string) error {
+ if deploy == nil || data == nil {
+ return errors.New("seed recording is not wired on this box")
+ }
+ if err := seeds.Check(host, id); err != nil {
+ return fmt.Errorf("seed %q of %s: %w", id, host, err)
+ }
+ _, err := provision.SetRecordSeed(ctx, deploy, acct, app, host, provision.SeedPlace(id))
+ if errors.Is(err, provision.ErrSeedConflict) {
+ rec, gerr := provision.GetRecord(ctx, deploy, acct, app, host)
+ if gerr != nil {
+ return gerr
+ }
+ if !placedWith(rec, id) {
+ return fmt.Errorf("seed %s of %s: %w", id, host, err)
+ }
+ } else if err != nil {
+ return err
+ }
+ if err := data.Delete(ctx, seeds.SnapshotKey(host, id)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("delete seed %s of %s: %w", id, host, err)
+ }
+ return nil
+ }
+}
+
+// placedWith reports whether rec's seed is id and has been placed: placed,
+// or committed, which only follows placed.
+func placedWith(rec *provision.Record, id string) bool {
+ return rec != nil && rec.Seed != nil && rec.Seed.ID == id &&
+ (rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted)
+}
+
+// armedSeedHooks is what reconcileSerialized hands the provision pass
+// (ReconcileConfig.Seeds). Nil until the activator is armed with a data
+// bucket: the pass then mints and finishes no seeded record.
+var armedSeedHooks atomic.Pointer[provision.SeedHooks]
+
+// armSeedHooks arms the provision pass's seed hooks. Hooks without a data
+// bucket are never armed: the pass's aborted-seed finish deletes through
+// it.
+func armSeedHooks(h *provision.SeedHooks) {
+ if h != nil && h.Data == nil {
+ h = nil
+ }
+ armedSeedHooks.Store(h)
+}
+
+// seedDataOpener opens this region's data bucket, where seeds/ lives.
+func seedDataOpener(bucket, region string) func() (release.Store, error) {
+ return func() (release.Store, error) {
+ s, err := release.NewS3StoreIn(context.Background(), bucket, region)
+ if err != nil {
+ return nil, err
+ }
+ return s, nil
+ }
+}
+
+// wireSeedPlacement arms seeded instances on this box (seeded-instances
+// spec §3.4): the claim flag that makes the seed fallback refuse, the data
+// bucket for snapshots, the record store for the placed CAS, and the
+// provision pass's hooks. The claim flag is set whatever else happens. The
+// rest is best-effort like the staging-fallback mirror, and all or nothing:
+// a box that cannot build both stores places no seed, and a seeded row's
+// wake fails instead of starting empty.
+func wireSeedPlacement(eb *activator.ExecBackend, claimArmed bool, openData, openDeploy func() (release.Store, error)) {
+ eb.ClaimArmed = claimArmed
+ data, err := openData()
+ if err != nil {
+ log.Printf("agent: seed reader: %v (no seed can be placed on this box)", err)
+ return
+ }
+ deploy, err := openDeploy()
+ if err != nil {
+ log.Printf("agent: seed recorder: %v (no seed can be placed on this box)", err)
+ return
+ }
+ eb.Seeds = storeSeedObjects{data}
+ eb.OnSeedPlaced = seedPlacedHook(deploy, data)
+ armSeedHooks(&provision.SeedHooks{Data: data})
+}
diff --git a/internal/host/seedwiring_test.go b/internal/host/seedwiring_test.go
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")
+ }
+}