package provision import ( "context" "encoding/json" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) func seedTestRecord(t *testing.T, s release.Store, seed *SeedSource) Record { t.Helper() 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, } if err := CreateRecord(context.Background(), s, rec); err != nil { t.Fatal(err) } return rec } func testSeed(t *testing.T, kind, state string) *SeedSource { t.Helper() id, err := seeds.NewID() if err != nil { t.Fatal(err) } return &SeedSource{ID: id, Kind: kind, State: state, By: "paul@rslw.com", At: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)} } func TestSeedWireShapeIsAdditive(t *testing.T) { const legacy = `{"schema":1,"host":"acme.example.com","slug":"acme","app":"shop",` + `"account":"bab","channel":"stable","created_at":"2026-08-04T09:00:00Z"}` var rec Record if err := json.Unmarshal([]byte(legacy), &rec); err != nil { t.Fatal(err) } if rec.Seed != nil { t.Fatalf("Seed = %+v, want nil on a record without one", rec.Seed) } out, _ := json.Marshal(rec) if strings.Contains(string(out), `"seed"`) { t.Fatalf("a seedless record encodes a seed key: %s", out) } rec.Seed = &SeedSource{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindImport, State: seeds.StateAwaitingUpload, Replaces: "127.0.0.1:8401", VerifyHash: strings.Repeat("b", 64)} out, _ = json.Marshal(rec) for _, want := range []string{`"seed":{"id":"sd_`, `"kind":"import"`, `"state":"awaiting-upload"`, `"replaces":"127.0.0.1:8401"`, `"verify_hash":"bbbb`} { if !strings.Contains(string(out), want) { t.Errorf("encoded record lacks %s: %s", want, out) } } } func TestOrdinaryWritersCannotChangeTheSeed(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StateReady)) // Through the pointer the mutation is handed: the scaffold's copy must // be deep, or this lands. _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Seed.State = seeds.StatePlaced return nil }) if !errors.Is(err, ErrSeedProtected) { t.Fatalf("an in-place seed edit through updateRecord: err = %v, want ErrSeedProtected", err) } _, err = updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Seed = nil return nil }) if !errors.Is(err, ErrSeedProtected) { t.Fatalf("dropping the seed through updateRecord: err = %v, want ErrSeedProtected", err) } // An ordinary write that leaves the seed alone carries it through. It // keeps the region: re-homing an unplaced seed is refused (Plan 8 Task 3). if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, rec.Region); err != nil { t.Fatal(err) } got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) if err != nil || got.Seed == nil || got.Seed.ID != rec.Seed.ID || got.Seed.State != seeds.StateReady { t.Fatalf("after SetRecordRegion: seed = %+v, %v; want it carried through", got.Seed, err) } // The etag-guarded writer refuses too. cur, etag, err := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host) if err != nil { t.Fatal(err) } cur.Seed.State = seeds.StateAborted if err := claimRawUpdateRecord(ctx, s, *cur, etag); !errors.Is(err, ErrSeedProtected) { t.Fatalf("claimRawUpdateRecord with a changed seed: err = %v, want ErrSeedProtected", err) } } func TestSeedPredicates(t *testing.T) { var none *SeedSource if none.Import() || none.BlocksRun() || none.MoveBlock() != "" || none.Abandoned() { t.Error("a nil seed must block nothing") } for _, c := range []struct { kind, state string blocksRun bool moveBlock string abandoned bool }{ {seeds.KindReplica, seeds.StateReserving, false, SeedMoveUnplaced, false}, {seeds.KindReplica, seeds.StateReady, false, SeedMoveUnplaced, false}, {seeds.KindReplica, seeds.StatePlaced, false, "", false}, {seeds.KindReplica, seeds.StateAborted, false, SeedMoveUnplaced, true}, {seeds.KindImport, seeds.StateAwaitingUpload, true, SeedMoveUncommitted, false}, {seeds.KindImport, seeds.StateSwapped, true, SeedMoveUncommitted, false}, {seeds.KindImport, seeds.StatePlaced, true, SeedMoveUncommitted, false}, {seeds.KindImport, seeds.StateCommitted, false, "", false}, {seeds.KindImport, seeds.StateRolledBack, true, SeedMoveUncommitted, true}, } { s := &SeedSource{Kind: c.kind, State: c.state} if s.BlocksRun() != c.blocksRun || s.MoveBlock() != c.moveBlock || s.Abandoned() != c.abandoned { t.Errorf("%s %s: BlocksRun %v MoveBlock %q Abandoned %v; want %v %q %v", c.kind, c.state, s.BlocksRun(), s.MoveBlock(), s.Abandoned(), c.blocksRun, c.moveBlock, c.abandoned) } } } 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) } } // A --from seed's abort records why; an empty reason keeps the one a // refused import already carries. func TestSeedAbortBecauseRecordsItsReason(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) seed := testSeed(t, seeds.KindReplica, seeds.StateReserving) rec := seedTestRecord(t, s, seed) got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortBecause(seed.ID, "the copy failed its check")) if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "the copy failed its check" { t.Fatalf("abort because = %+v, %v", got.Seed, err) } 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, SeedRefuse(seed.ID, "the route belongs to app blog")); err != nil { t.Fatal(err) } got, err = SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(seed.ID)) if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "the route belongs to app blog" { t.Fatalf("abort of a refused import = %+v, %v; want its refusal reason kept", got.Seed, err) } } // The upload's abort records why, and refuses an import that has moved // on from awaiting-upload: it never aborts another upload's work. func TestSeedAbortUploadOnlyWhileAwaiting(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) seed := testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload) rec := seedTestRecord(t, s, seed) got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortUploadBecause(seed.ID, "the upload failed its check")) if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "the upload failed its check" { t.Fatalf("abort upload = %+v, %v", got.Seed, err) } s = release.NewDirStore(t.TempDir()) seed = testSeed(t, seeds.KindImport, seeds.StateAwaitingUpload) rec = seedTestRecord(t, s, seed) if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedUploaded(seed.ID)); err != nil { t.Fatal(err) } if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortUploadBecause(seed.ID, "late")); !errors.Is(err, ErrSeedConflict) { t.Fatalf("abort upload of an uploaded import: err = %v, want ErrSeedConflict", err) } if after, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || after.Seed.State != seeds.StateUploaded || after.Seed.Reason != "" { t.Fatalf("after a refused upload abort = %+v, %v; want it uploaded", after.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}}, "abort upload": {func(id string) func(*Record) error { return SeedAbortUploadBecause(id, "no") }, map[cell]string{{seeds.KindImport, seeds.StateAwaitingUpload}: 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) } } // The sweep's abort lands only on the seed exactly as the sweep read it: // the same id, in the same state, since the same At. A seed that moved on // (published, stopped) or was re-stamped is refused, and so is a state // SeedAbort itself refuses. func TestSeedAbortStaleOnlyFromWhatWasRead(t *testing.T) { ctx := context.Background() for _, c := range []struct{ kind, state string }{ {seeds.KindReplica, seeds.StateReserving}, {seeds.KindImport, seeds.StateAwaitingUpload}, {seeds.KindImport, seeds.StateUploaded}, } { s := release.NewDirStore(t.TempDir()) seed := testSeed(t, c.kind, c.state) rec := seedTestRecord(t, s, seed) got, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, c.state, seed.At)) if err != nil || got.Seed.State != seeds.StateAborted || got.Seed.Reason != "" { t.Fatalf("%s %s: %+v, %v", c.kind, c.state, got, err) } } s := release.NewDirStore(t.TempDir()) seed := testSeed(t, seeds.KindImport, seeds.StateUploaded) rec := seedTestRecord(t, s, seed) other, _ := seeds.NewID() for name, mut := range map[string]func(*Record) error{ "another id": SeedAbortStale(other, seeds.StateUploaded, seed.At), "another state": SeedAbortStale(seed.ID, seeds.StateAwaitingUpload, seed.At), "another moment": SeedAbortStale(seed.ID, seeds.StateUploaded, seed.At.Add(time.Second)), } { if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, mut); !errors.Is(err, ErrSeedConflict) { t.Fatalf("%s: err = %v, want ErrSeedConflict", name, err) } } // The app stops it: the seed moved on, and the abort read before that // is refused. if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedStopped(seed.ID)); err != nil { t.Fatal(err) } if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, seeds.StateUploaded, seed.At)); !errors.Is(err, ErrSeedConflict) { t.Fatalf("after the stop: err = %v, want ErrSeedConflict", err) } got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) if err != nil || got.Seed.State != seeds.StateReady { t.Fatalf("after a refused stale abort: %+v, %v; want ready", got, err) } // A state SeedAbort refuses stays refused: a placed --from seed. s = release.NewDirStore(t.TempDir()) seed = testSeed(t, seeds.KindReplica, seeds.StatePlaced) rec = seedTestRecord(t, s, seed) if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbortStale(seed.ID, seeds.StatePlaced, seed.At)); !errors.Is(err, ErrSeedConflict) { t.Fatalf("placed --from: err = %v, want ErrSeedConflict", err) } } func TestSeedMoveSentence(t *testing.T) { for block, want := range map[string]string{ SeedMoveUnplaced: "wake the instance once before moving it", SeedMoveUncommitted: "commit or abort the import first", "": "", "other": "", } { if got := SeedMoveSentence(block); got != want { t.Errorf("SeedMoveSentence(%q) = %q, want %q", block, got, want) } } } // validMoveStart is a start MoveStartTransition accepts on its own: the // seed refusal is then the only reason it can refuse (preflight m4). func validMoveStart(from string) MoveStart { return MoveStart{ID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Incarnation: strings.Repeat("a", 64), From: from, To: "uk", Cut: "auto", By: "op", Now: time.Now()} } func TestMoveTransitionsRefuseAnUnplacedSeed(t *testing.T) { ctx := context.Background() for _, c := range []struct { kind, state, block, want string }{ {seeds.KindReplica, seeds.StateReady, SeedMoveUnplaced, "wake the instance once before moving it"}, {seeds.KindReplica, seeds.StateAborted, SeedMoveUnplaced, "wake the instance once before moving it"}, {seeds.KindImport, seeds.StatePlaced, SeedMoveUncommitted, "commit or abort the import first"}, {seeds.KindImport, seeds.StateAwaitingUpload, SeedMoveUncommitted, "commit or abort the import first"}, } { s := release.NewDirStore(t.TempDir()) rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state)) for name, mut := range map[string]func(*Record) error{ "request": MoveRequest("uk", time.Now()), "start": MoveStartTransition(validMoveStart(rec.Region)), } { _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut) var refusal *SeedMoveError if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) || !errors.As(err, &refusal) || refusal.Block != c.block { t.Fatalf("%s %s %s: err = %v", c.kind, c.state, name, err) } } got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) if err != nil || got.Move != nil { t.Fatalf("%s %s: a refused move left a journal: %+v, %v", c.kind, c.state, got, err) } } for _, c := range []struct{ kind, state string }{ {seeds.KindReplica, seeds.StatePlaced}, {seeds.KindImport, seeds.StateCommitted}, } { for name, mut := range map[string]func(*Record) error{ "request": MoveRequest("uk", time.Now()), "start": MoveStartTransition(validMoveStart("")), } { s := release.NewDirStore(t.TempDir()) rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state)) if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil { t.Fatalf("%s %s %s: %v", c.kind, c.state, name, err) } } } } // SetRecordRegion is carlos apps place's write: it re-homes a record, so it // refuses a seed that may not move, under its CAS (preflight M8(a)). A // write that leaves the region as it is moves nothing and is let through. func TestSetRecordRegionRefusesAnUnplacedSeed(t *testing.T) { ctx := context.Background() for _, c := range []struct{ kind, state, want string }{ {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"}, {seeds.KindImport, seeds.StateSwapped, "commit or abort the import first"}, } { s := release.NewDirStore(t.TempDir()) rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state)) err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "fleet/pi") if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) { t.Fatalf("%s %s: err = %v", c.kind, c.state, err) } if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Region != rec.Region { t.Fatalf("%s %s: a refused re-home wrote: %+v, %v", c.kind, c.state, got, err) } if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, rec.Region); err != nil { t.Fatalf("%s %s: a write that keeps the region: %v", c.kind, c.state, err) } } s := release.NewDirStore(t.TempDir()) rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StatePlaced)) if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "fleet/pi"); err != nil { t.Fatalf("a placed seed: %v", err) } if got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || got.Region != "fleet/pi" { t.Fatalf("a placed seed after the re-home: %+v, %v", got, err) } } // Merge review S2: the states claim activation refuses over, exactly. func TestSeedUnfinished(t *testing.T) { var none *SeedSource if none.Unfinished() { t.Fatal("a record with no seed is unfinished") } for _, c := range []struct { kind, state string want bool }{ {seeds.KindReplica, seeds.StateReserving, true}, {seeds.KindReplica, seeds.StateReady, true}, {seeds.KindReplica, seeds.StatePlaced, false}, {seeds.KindReplica, seeds.StateAborted, true}, {seeds.KindReplica, seeds.StateRolledBack, true}, {seeds.KindImport, seeds.StateAwaitingUpload, true}, {seeds.KindImport, seeds.StateUploaded, true}, {seeds.KindImport, seeds.StateReady, true}, {seeds.KindImport, seeds.StateSwapped, true}, {seeds.KindImport, seeds.StatePlaced, true}, {seeds.KindImport, seeds.StateCommitted, false}, {seeds.KindImport, seeds.StateRefused, true}, {seeds.KindImport, "a state from a later build", true}, {seeds.KindImport, seeds.StateAborted, true}, {seeds.KindImport, seeds.StateRolledBack, true}, {seeds.KindReplica, seeds.StateCommitted, true}, {"a kind from a later build", seeds.StatePlaced, true}, {"a kind from a later build", seeds.StateCommitted, true}, {"", seeds.StateCommitted, true}, } { if got := (&SeedSource{Kind: c.kind, State: c.state}).Unfinished(); got != c.want { t.Errorf("%s %s: unfinished %v, want %v", c.kind, c.state, got, c.want) } } }