carlos / platform Public

provision: the seed transitions and the row column (Plan 2 Task 3)

SetRecordSeed and the ten seed transitions on updateRecordSeeding, each
scoped to its seed id, kind and from-states; ErrSeedConflict; SeedColumn.
An import's abort is refused while a run is open (RunOpenError).

Carried from Task 2: guard tests for adding a seed to a seedless record
and for claimRawUpdateRecord dropping one; the run-series tests use the
seeds constants and the SeedKindImport/SeedCommitted aliases are gone;
the two test workarounds now go through SetRecordSeed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3f3de6fe1f4b165815d11f39eb498c099aea19a2 parent ea96323
6 files changed, +444 −32
  • internal/console/apiruns_test.go +2 −1
  • internal/host/runexec_linux_test.go +8 −12
  • internal/provision/run.go +0 −8
  • internal/provision/run_test.go +28 −11
  • internal/provision/seed.go +145 −0
  • internal/provision/seed_test.go +261 −0

CI cancelled — superseded by a newer push — run details

diff --git a/internal/console/apiruns_test.go b/internal/console/apiruns_test.go
index 08ef18226..822f1f169 100644
--- a/internal/console/apiruns_test.go
+++ b/internal/console/apiruns_test.go
@@ -19,6 +19,7 @@ import (
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/runs"
+ "github.com/carlosframework/platform/internal/seeds"
"github.com/carlosframework/platform/internal/svcrole"
)
@@ -248,7 +249,7 @@ func TestAPIRunCreateRefusesInOrder(t *testing.T) {
t.Errorf("%q: %d %s", c.msg, status, body)
}
}
- putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: provision.SeedKindImport, State: "placed"}})
+ putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: seeds.KindImport, State: seeds.StatePlaced}})
if status, body := postRun(t, fx.runsURL("import.example.com"), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != "import.example.com is an import that is not committed yet. finish or abort the import first" {
t.Errorf("import: %d %s", status, body)
}
diff --git a/internal/host/runexec_linux_test.go b/internal/host/runexec_linux_test.go
index 7e3082b7a..d7933bf02 100644
--- a/internal/host/runexec_linux_test.go
+++ b/internal/host/runexec_linux_test.go
@@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
- "encoding/json"
"errors"
"io"
"os"
@@ -19,6 +18,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/runs"
+ "github.com/carlosframework/platform/internal/seeds"
)
type fakeHandle struct {
@@ -383,17 +383,13 @@ func TestRunRefusesAnIncarnationThatDoesNotMatch(t *testing.T) {
func TestRunRefusesAnImportUncommittedAtLaunch(t *testing.T) {
s := release.NewDirStore(t.TempDir())
rec, req := pickedRun(t, s, 60)
- cur, etag, err := provision.GetRecordETag(context.Background(), s, rec.Account, rec.App, rec.Host)
- if err != nil {
- t.Fatal(err)
- }
- cur.Seed = &provision.SeedSource{Kind: provision.SeedKindImport, State: "placed"}
- // A seed is written by a seed transition, not UpdateRecord: put the body.
- body, err := json.Marshal(cur)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := s.Put(context.Background(), provision.RecordKey(cur.Account, cur.App, cur.Host), body, etag); err != nil {
+ // Admission refuses an uncommitted import, so no walk of the seed
+ // transitions reaches a picked run on one: the launch check is the
+ // backstop. Give the record its seed through the seed writer directly.
+ if _, err := provision.SetRecordSeed(context.Background(), s, rec.Account, rec.App, rec.Host, func(r *provision.Record) error {
+ r.Seed = &provision.SeedSource{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindImport, State: seeds.StatePlaced}
+ return nil
+ }); err != nil {
t.Fatal(err)
}
if res := runWith(t, s, &fakeLauncher{handle: &fakeHandle{}}, rec, req, newRunHandle()); res.Refused != runs.RefusedImport {
diff --git a/internal/provision/run.go b/internal/provision/run.go
index 4d146dcf9..b40a226e9 100644
--- a/internal/provision/run.go
+++ b/internal/provision/run.go
@@ -7,7 +7,6 @@ import (
"time"
"github.com/carlosframework/platform/internal/release"
- "github.com/carlosframework/platform/internal/seeds"
)
// RunState is a `carlos run` on the instance record (run spec §5.1). Box
@@ -40,13 +39,6 @@ func (r *RunState) Open() bool {
return r != nil && (r.State == RunQueued || r.State == RunPicked || r.State == RunCancel)
}
-// The seed kind and state the run series reads; the values are the seeds
-// package's.
-const (
- SeedKindImport = seeds.KindImport
- SeedCommitted = seeds.StateCommitted
-)
-
// ImportUncommitted reports whether the record's seed is an import that is
// not committed yet (seeded spec §5.4): a run is refused then, at the door
// and again at launch (run spec §5.1 step 9, codex R2-2).
diff --git a/internal/provision/run_test.go b/internal/provision/run_test.go
index 7c53a995f..504cc499b 100644
--- a/internal/provision/run_test.go
+++ b/internal/provision/run_test.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
)
func runTestRecord(t *testing.T, s release.Store) Record {
@@ -122,19 +123,35 @@ func TestRunAdmitRefusesAMoveAndAnUncommittedImport(t *testing.T) {
if _, err := setRun(t, s, rec, RunAdmit(RunAdmission{ID: "run_a", Deadline: at})); !errors.Is(err, ErrRunMoving) {
t.Fatalf("admit while moving: %v, want ErrRunMoving", err)
}
+ // An import walks its path by the seed transitions; admission is tried
+ // at each state on the way.
s2 := release.NewDirStore(t.TempDir())
- imp := runTestRecord(t, s2)
- for state, refused := range map[string]bool{"swapped": true, "placed": true, "committed": false} {
- // Setting a seed is a seed transition's job; ordinary writers are refused.
- if _, err := updateRecordSeeding(context.Background(), s2, imp.Account, imp.App, imp.Host, func(r *Record) error {
- r.Seed, r.ActiveRun = &SeedSource{Kind: SeedKindImport, State: state}, nil
- return nil
- }, true); err != nil {
- t.Fatal(err)
+ seedID, err := seeds.NewID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ imp := Record{Schema: 1, Host: "a.go.example.com", Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(),
+ Seed: &SeedSource{ID: seedID, Kind: seeds.KindImport, State: seeds.StateAwaitingUpload}}
+ if err := CreateRecord(context.Background(), s2, imp); err != nil {
+ t.Fatal(err)
+ }
+ for _, step := range []struct {
+ mut func(*Record) error
+ state string
+ refused bool
+ }{
+ {SeedUploaded(seedID), seeds.StateUploaded, true},
+ {SeedStopped(seedID), seeds.StateReady, true},
+ {SeedSwapped(seedID), seeds.StateSwapped, true},
+ {SeedPlace(seedID), seeds.StatePlaced, true},
+ {SeedCommit(seedID), seeds.StateCommitted, false},
+ } {
+ if _, err := SetRecordSeed(context.Background(), s2, imp.Account, imp.App, imp.Host, step.mut); err != nil {
+ t.Fatalf("to %s: %v", step.state, err)
}
- _, err := setRun(t, s2, imp, RunAdmit(RunAdmission{ID: "run_" + state, Deadline: at}))
- if refused != errors.Is(err, ErrRunImport) {
- t.Errorf("import %s: err %v, refused want %v (codex R2-2)", state, err, refused)
+ _, err := setRun(t, s2, imp, RunAdmit(RunAdmission{ID: "run_" + step.state, Deadline: at}))
+ if step.refused != errors.Is(err, ErrRunImport) {
+ t.Errorf("import %s: err %v, refused want %v (codex R2-2)", step.state, err, step.refused)
}
}
}
diff --git a/internal/provision/seed.go b/internal/provision/seed.go
index 64febc2b4..084b04f12 100644
--- a/internal/provision/seed.go
+++ b/internal/provision/seed.go
@@ -1,9 +1,13 @@
package provision
import (
+ "context"
"errors"
+ "fmt"
+ "slices"
"time"
+ "github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/seeds"
)
@@ -84,3 +88,144 @@ func seedEqual(a, b *SeedSource) bool {
x.At, y.At = time.Time{}, time.Time{}
return x == y
}
+
+// ErrSeedConflict is a seed transition the record's seed does not allow:
+// another seed's id, the wrong kind, or the wrong state. It is the answer,
+// not a race (a race is retried once by the scaffold, which re-reads and
+// re-applies the transition).
+var ErrSeedConflict = errors.New("provision: seed transition refused")
+
+// SetRecordSeed applies one seed transition to the record by CAS on its
+// etag, retrying once on a lost race (updateRecord's scaffold). It is the
+// only writer of Record.Seed after creation.
+func SetRecordSeed(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) (*Record, error) {
+ return updateRecordSeeding(ctx, s, acct, app, host, mut, true)
+}
+
+// seedIn checks one row of the transition table: the record's seed must be
+// id, of kind (any kind when ""), in one of from.
+func seedIn(rec *Record, id, kind string, from ...string) error {
+ switch {
+ case rec.Seed == nil || rec.Seed.ID != id:
+ return fmt.Errorf("%w: the record's seed is not %s", ErrSeedConflict, id)
+ case kind != "" && rec.Seed.Kind != kind:
+ return fmt.Errorf("%w: seed %s is a %s seed", ErrSeedConflict, id, rec.Seed.Kind)
+ case !slices.Contains(from, rec.Seed.State):
+ return fmt.Errorf("%w: seed %s is %s", ErrSeedConflict, id, rec.Seed.State)
+ }
+ return nil
+}
+
+// seedStep is a transition that is one row of the table: checked by
+// seedIn, then the seed moves to to and also (when set) records the rest.
+func seedStep(id, kind string, from []string, to string, also func(*SeedSource)) func(*Record) error {
+ return func(rec *Record) error {
+ if err := seedIn(rec, id, kind, from...); err != nil {
+ return err
+ }
+ rec.Seed.State = to
+ if also != nil {
+ also(rec.Seed)
+ }
+ return nil
+ }
+}
+
+// SeedPublishReplica is --from's publish (spec §4 step 7.3): reserving to
+// ready, with what the restore actually produced.
+func SeedPublishReplica(id, txid, sha string, bytes int64) func(*Record) error {
+ return seedStep(id, seeds.KindReplica, []string{seeds.StateReserving}, seeds.StateReady, func(s *SeedSource) {
+ s.TXID, s.SHA256, s.Bytes = txid, sha, bytes
+ })
+}
+
+// SeedUploaded is the upload's last step (spec §5.2 step 5).
+func SeedUploaded(id string) func(*Record) error {
+ return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload}, seeds.StateUploaded, nil)
+}
+
+// SeedStopped is the app saying its own process is down (spec §5.3).
+func SeedStopped(id string) func(*Record) error {
+ return seedStep(id, seeds.KindImport, []string{seeds.StateUploaded}, seeds.StateReady, nil)
+}
+
+// SeedSwapped is the box's CAS after its swap transaction (spec §5.4).
+func SeedSwapped(id string) func(*Record) error {
+ return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateSwapped, nil)
+}
+
+// SeedPlace is the box's CAS after the first upload of the host's own
+// replica (spec §3.4 step 5): a --from seed from ready, an import from
+// swapped. An import is never placed from ready: that would skip its swap.
+func SeedPlace(id string) func(*Record) error {
+ return func(rec *Record) error {
+ from := seeds.StateReady
+ if rec.Seed.Import() {
+ from = seeds.StateSwapped
+ }
+ return seedStep(id, "", []string{from}, seeds.StatePlaced, nil)(rec)
+ }
+}
+
+// SeedCommit is the app's verdict that the instance serves (spec §5.4):
+// refused before placed, because nothing has been verified yet.
+func SeedCommit(id string) func(*Record) error {
+ return seedStep(id, seeds.KindImport, []string{seeds.StatePlaced}, seeds.StateCommitted, nil)
+}
+
+// SeedRefuse is the box declining the swap, naming why (spec §5.4).
+func SeedRefuse(id, reason string) func(*Record) error {
+ return seedStep(id, seeds.KindImport, []string{seeds.StateReady}, seeds.StateRefused, func(s *SeedSource) {
+ s.Reason = reason
+ })
+}
+
+// SeedAbort abandons a seed (spec §5.6, §4 step 7): an import from any
+// state before committed, a --from seed while reserving or ready.
+//
+// An import's abort is also refused while a run is open on the instance
+// (spec §5.6, run spec §5.1), although run admission already refuses an
+// uncommitted import: a RunOpenError, so errors.Is(err, ErrRunOpen). Both
+// specs scope that rule to the import; a --from seed's abort is its door's
+// own failure path, and a run admitted on the reserved record must not
+// leave it stuck reserving.
+func SeedAbort(id string) func(*Record) error {
+ return func(rec *Record) error {
+ from := []string{seeds.StateReserving, seeds.StateReady}
+ if rec.Seed.Import() {
+ from = []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady,
+ seeds.StateSwapped, seeds.StatePlaced, seeds.StateRefused}
+ }
+ if err := seedIn(rec, id, "", from...); err != nil {
+ return err
+ }
+ if rec.Seed.Import() && rec.ActiveRun.Open() {
+ return &RunOpenError{Run: *rec.ActiveRun}
+ }
+ rec.Seed.State = seeds.StateAborted
+ return nil
+ }
+}
+
+// SeedRolledBack is the box's last word on an aborted seed (spec §5.6, and
+// §3.2's no-journal path). The console finishes from here.
+func SeedRolledBack(id string) func(*Record) error {
+ return seedStep(id, "", []string{seeds.StateAborted}, seeds.StateRolledBack, nil)
+}
+
+// SeedRolledBackBeforeSwap is the delete door's shortcut for an import no
+// box has swapped (spec §5.6, codex R2-7).
+func SeedRolledBackBeforeSwap(id string) func(*Record) error {
+ return seedStep(id, seeds.KindImport, []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused},
+ seeds.StateRolledBack, nil)
+}
+
+// SeedColumn is the registry column a record's row carries (seed_source):
+// "" for a seedless record.
+func SeedColumn(rec Record) string {
+ if rec.Seed == nil {
+ return ""
+ }
+ return seeds.RowSeed{ID: rec.Seed.ID, Kind: rec.Seed.Kind, SHA256: rec.Seed.SHA256,
+ Bytes: rec.Seed.Bytes, State: rec.Seed.State}.Encode()
+}
diff --git a/internal/provision/seed_test.go b/internal/provision/seed_test.go
index 9aad56346..7d5c06798 100644
--- a/internal/provision/seed_test.go
+++ b/internal/provision/seed_test.go
@@ -125,3 +125,264 @@ func TestSeedPredicates(t *testing.T) {
}
}
}
+
+func TestSeedImportWalksItsPath(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload)
+ rec := seedTestRecord(t, s, seed)
+ for _, step := range []struct {
+ mut func(*Record) error
+ want string
+ }{
+ {SeedUploaded(seed.ID), seeds.StateUploaded},
+ {SeedStopped(seed.ID), seeds.StateReady},
+ {SeedSwapped(seed.ID), seeds.StateSwapped},
+ {SeedPlace(seed.ID), seeds.StatePlaced},
+ {SeedCommit(seed.ID), seeds.StateCommitted},
+ } {
+ got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, step.mut)
+ if err != nil || got.Seed.State != step.want {
+ t.Fatalf("to %s: %+v, %v", step.want, got, err)
+ }
+ }
+ // After commit, abort is refused (spec §5.6).
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrSeedConflict) {
+ t.Fatalf("abort after commit: err = %v, want ErrSeedConflict", err)
+ }
+}
+
+func TestSeedReplicaPublishesWhatWasRestored(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindReplica, seeds.StateReserving)
+ seed.From = "acme.example.com"
+ rec := seedTestRecord(t, s, seed)
+ sha := strings.Repeat("c", 64)
+ got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPublishReplica(seed.ID, "4211", sha, 8192))
+ if err != nil || got.Seed.State != seeds.StateReady || got.Seed.TXID != "4211" || got.Seed.SHA256 != sha || got.Seed.Bytes != 8192 {
+ t.Fatalf("publish: %+v, %v", got.Seed, err)
+ }
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedCommit(seed.ID)); !errors.Is(err, ErrSeedConflict) {
+ t.Fatalf("commit of a replica seed: err = %v, want ErrSeedConflict", err)
+ }
+}
+
+func TestSeedPlaceFollowsTheKind(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, seeds.StateReady)
+ rec := seedTestRecord(t, s, seed)
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPlace(seed.ID)); !errors.Is(err, ErrSeedConflict) {
+ t.Fatalf("an import placed from ready: err = %v, want ErrSeedConflict", err)
+ }
+}
+
+func TestSeedTransitionsAreScopedToTheirID(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, seeds.StateAborted)
+ rec := seedTestRecord(t, s, seed)
+ other, _ := seeds.NewID()
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(other)); !errors.Is(err, ErrSeedConflict) {
+ t.Fatalf("another id's rollback: err = %v, want ErrSeedConflict", err)
+ }
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(seed.ID)); err != nil {
+ t.Fatalf("its own rollback: %v", err)
+ }
+}
+
+func TestSeedAbortAndRollbackBeforeSwap(t *testing.T) {
+ ctx := context.Background()
+ for _, state := range []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused} {
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, state)
+ rec := seedTestRecord(t, s, seed)
+ got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID))
+ if err != nil || got.Seed.State != seeds.StateRolledBack {
+ t.Fatalf("%s: %+v, %v", state, got, err)
+ }
+ }
+ for _, state := range []string{seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced} {
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, state)
+ rec := seedTestRecord(t, s, seed)
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBackBeforeSwap(seed.ID)); !errors.Is(err, ErrSeedConflict) {
+ t.Fatalf("%s straight to rolled-back: err = %v, want ErrSeedConflict", state, err)
+ }
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil {
+ t.Fatalf("%s abort: %v", state, err)
+ }
+ }
+}
+
+func TestSeedRefuseRecordsItsReason(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, seeds.StateReady)
+ rec := seedTestRecord(t, s, seed)
+ got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(seed.ID, "the route belongs to app blog"))
+ if err != nil || got.Seed.State != seeds.StateRefused || got.Seed.Reason != "the route belongs to app blog" {
+ t.Fatalf("refuse: %+v, %v", got.Seed, err)
+ }
+}
+
+func TestSeedColumn(t *testing.T) {
+ if SeedColumn(Record{}) != "" {
+ t.Error("a seedless record has a column value")
+ }
+ seed := testSeed(t, seeds.KindReplica, seeds.StateReady)
+ seed.SHA256, seed.Bytes = strings.Repeat("d", 64), 12
+ got, err := seeds.ParseRowSeed(SeedColumn(Record{Seed: seed}))
+ if err != nil || got == nil || got.ID != seed.ID || got.Kind != seeds.KindReplica || got.SHA256 != seed.SHA256 || got.Bytes != 12 || got.State != seeds.StateReady {
+ t.Fatalf("column = %+v, %v", got, err)
+ }
+}
+
+// The whole transition table (spec §3.2, §5.6), checked cell by cell: every
+// transition, for each kind, from every state, under its own id and under
+// another. A cell not in the table is ErrSeedConflict and leaves the seed
+// as it was.
+func TestSeedTransitionTable(t *testing.T) {
+ all := []string{seeds.StateReserving, seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady,
+ seeds.StateSwapped, seeds.StatePlaced, seeds.StateCommitted, seeds.StateRefused, seeds.StateAborted, seeds.StateRolledBack}
+ type cell struct{ kind, from string }
+ table := map[string]struct {
+ mut func(id string) func(*Record) error
+ to map[cell]string
+ }{
+ "publish": {func(id string) func(*Record) error { return SeedPublishReplica(id, "1", strings.Repeat("e", 64), 1) },
+ map[cell]string{{seeds.KindReplica, seeds.StateReserving}: seeds.StateReady}},
+ "uploaded": {SeedUploaded, map[cell]string{{seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateUploaded}},
+ "stopped": {SeedStopped, map[cell]string{{seeds.KindImport, seeds.StateUploaded}: seeds.StateReady}},
+ "swapped": {SeedSwapped, map[cell]string{{seeds.KindImport, seeds.StateReady}: seeds.StateSwapped}},
+ "place": {SeedPlace, map[cell]string{
+ {seeds.KindReplica, seeds.StateReady}: seeds.StatePlaced,
+ {seeds.KindImport, seeds.StateSwapped}: seeds.StatePlaced}},
+ "commit": {SeedCommit, map[cell]string{{seeds.KindImport, seeds.StatePlaced}: seeds.StateCommitted}},
+ "refuse": {func(id string) func(*Record) error { return SeedRefuse(id, "no") },
+ map[cell]string{{seeds.KindImport, seeds.StateReady}: seeds.StateRefused}},
+ "abort": {SeedAbort, map[cell]string{
+ {seeds.KindReplica, seeds.StateReserving}: seeds.StateAborted,
+ {seeds.KindReplica, seeds.StateReady}: seeds.StateAborted,
+ {seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateAborted,
+ {seeds.KindImport, seeds.StateUploaded}: seeds.StateAborted,
+ {seeds.KindImport, seeds.StateReady}: seeds.StateAborted,
+ {seeds.KindImport, seeds.StateSwapped}: seeds.StateAborted,
+ {seeds.KindImport, seeds.StatePlaced}: seeds.StateAborted,
+ {seeds.KindImport, seeds.StateRefused}: seeds.StateAborted}},
+ "rolled back": {SeedRolledBack, map[cell]string{
+ {seeds.KindReplica, seeds.StateAborted}: seeds.StateRolledBack,
+ {seeds.KindImport, seeds.StateAborted}: seeds.StateRolledBack}},
+ "rolled back before swap": {SeedRolledBackBeforeSwap, map[cell]string{
+ {seeds.KindImport, seeds.StateAwaitingUpload}: seeds.StateRolledBack,
+ {seeds.KindImport, seeds.StateUploaded}: seeds.StateRolledBack,
+ {seeds.KindImport, seeds.StateRefused}: seeds.StateRolledBack}},
+ }
+ other, err := seeds.NewID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, tr := range table {
+ for _, kind := range []string{seeds.KindReplica, seeds.KindImport} {
+ for _, from := range all {
+ seed := testSeed(t, kind, from)
+ // Under another id, no cell moves.
+ rec := Record{Seed: cloneSeed(seed)}
+ if err := tr.mut(other)(&rec); !errors.Is(err, ErrSeedConflict) || !seedEqual(rec.Seed, seed) {
+ t.Errorf("%s, %s %s, another id: err %v, seed %+v", name, kind, from, err, rec.Seed)
+ }
+ rec = Record{Seed: cloneSeed(seed)}
+ err := tr.mut(seed.ID)(&rec)
+ want, ok := tr.to[cell{kind, from}]
+ switch {
+ case ok && (err != nil || rec.Seed.State != want):
+ t.Errorf("%s, %s %s: err %v, state %s; want %s", name, kind, from, err, rec.Seed.State, want)
+ case !ok && (!errors.Is(err, ErrSeedConflict) || !seedEqual(rec.Seed, seed)):
+ t.Errorf("%s, %s %s: err %v, seed %+v; want ErrSeedConflict and no change", name, kind, from, err, rec.Seed)
+ }
+ }
+ }
+ // A seedless record has nothing to move.
+ var rec Record
+ if err := tr.mut(other)(&rec); !errors.Is(err, ErrSeedConflict) || rec.Seed != nil {
+ t.Errorf("%s on a seedless record: err %v, seed %+v", name, err, rec.Seed)
+ }
+ }
+}
+
+// An import's abort is refused while a run is open on the instance (spec
+// §5.6, run spec §5.1), although run admission already refuses an
+// uncommitted import. Once the run is terminal the abort goes through.
+func TestSeedAbortOfAnImportWaitsForAnOpenRun(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindImport, seeds.StatePlaced)
+ rec := Record{
+ Schema: 1, Host: "canary.example.com", Slug: "canary", App: "shop", Account: "bae",
+ Channel: "stable", CreatedAt: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC), Seed: seed,
+ ActiveRun: &RunState{ID: "run_a", State: RunQueued, Deadline: time.Now().Add(time.Minute)},
+ }
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); !errors.Is(err, ErrRunOpen) {
+ t.Fatalf("abort with a run open: err = %v, want ErrRunOpen", err)
+ }
+ if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Seed.State != seeds.StatePlaced {
+ t.Fatalf("after the refusal: %+v, %v; want the seed still placed", got, err)
+ }
+ if _, err := SetRecordRun(ctx, s, rec.Account, rec.App, rec.Host, RunExpire("run_a")); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil || got.Seed.State != seeds.StateAborted {
+ t.Fatalf("abort with the run expired: %+v, %v", got, err)
+ }
+}
+
+// A --from seed's abort is the door's own failure path (spec §4 step 7): a
+// run admitted on the reserved record must not leave it stuck reserving.
+func TestSeedAbortOfAReplicaIgnoresAnOpenRun(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ seed := testSeed(t, seeds.KindReplica, seeds.StateReserving)
+ rec := seedTestRecord(t, s, seed)
+ if _, err := SetRecordRun(ctx, s, rec.Account, rec.App, rec.Host, RunAdmit(RunAdmission{ID: "run_a", Deadline: time.Now().Add(time.Minute)})); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)); err != nil || got.Seed.State != seeds.StateAborted {
+ t.Fatalf("abort: %+v, %v", got, err)
+ }
+}
+
+// The guard's other two directions (Task 2): an ordinary writer cannot give
+// a seedless record a seed, and the etag-guarded writer cannot drop one.
+func TestOrdinaryWritersCannotAddOrDropASeed(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := seedTestRecord(t, s, nil)
+ _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
+ r.Seed = testSeed(t, seeds.KindImport, seeds.StateCommitted)
+ return nil
+ })
+ if !errors.Is(err, ErrSeedProtected) {
+ t.Fatalf("adding a seed through updateRecord: err = %v, want ErrSeedProtected", err)
+ }
+ if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Seed != nil {
+ t.Fatalf("after the refusal: seed = %+v, %v; want none", got.Seed, err)
+ }
+
+ s2 := release.NewDirStore(t.TempDir())
+ seeded := seedTestRecord(t, s2, testSeed(t, seeds.KindReplica, seeds.StatePlaced))
+ cur, etag, err := GetRecordETag(ctx, s2, seeded.Account, seeded.App, seeded.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cur.Seed = nil
+ if err := claimRawUpdateRecord(ctx, s2, *cur, etag); !errors.Is(err, ErrSeedProtected) {
+ t.Fatalf("claimRawUpdateRecord dropping the seed: err = %v, want ErrSeedProtected", err)
+ }
+ if got, err := GetRecord(ctx, s2, seeded.Account, seeded.App, seeded.Host); err != nil || got.Seed == nil || got.Seed.ID != seeded.Seed.ID {
+ t.Fatalf("after the refusal: seed = %+v, %v; want it kept", got.Seed, err)
+ }
+}