| new file mode 100644 |
| index 000000000..c8dc81177 |
| --- /dev/null |
| +++ b/internal/provision/seedswap_test.go |
| @@ -0,0 +1,594 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// shopSetup is the import tests' neutral fixture: an account manifest |
| +// (id acctID) and its app "shop" (domain shop.example.com, the shared |
| +// pool, no seed command). |
| +func shopSetup(t *testing.T) (*release.DirStore, string) { |
| + t.Helper() |
| + s := dirStore(t) |
| + sqid := writeAccountManifest(t, s, acctID, "Shop") |
| + if err := PutManifest(ctx, s, sqid, "shop", Manifest{Schema: 1, App: "shop", Domain: "shop.example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, sqid |
| +} |
| + |
| +func shopRecord(sqid string) Record { |
| + return Record{Schema: 1, Host: "a.shop.example.com", Slug: "a", App: "shop", |
| + Account: sqid, Region: "ie", Channel: "stable", CreatedAt: time.Now()} |
| +} |
| + |
| +func newImport(sqid string) Record { |
| + rec := shopRecord(sqid) |
| + id, _ := seeds.NewID() |
| + rec.Seed = &SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateReady, SHA256: strings.Repeat("a", 64), Bytes: 10, |
| + Replaces: "127.0.0.1:8401", VerifyHash: strings.Repeat("b", 64), By: "t", At: time.Now()} |
| + return rec |
| +} |
| + |
| +func importRecord(t *testing.T, s release.Store, sqid string) Record { |
| + t.Helper() |
| + rec := newImport(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| +// addrRow is the legacy row an import replaces: an addr route to the |
| +// app's own process, not minted from any record. |
| +func addrRow(rec Record, account string) registry.Route { |
| + return registry.Route{Host: rec.Host, Kind: "service", Addr: "127.0.0.1:8401", App: rec.App, Account: account, |
| + Region: "ie", Channel: "stable", Status: "ready", Unit: "shop@a.service"} |
| +} |
| + |
| +func importCfg(t *testing.T, owners map[string]seeds.LegacyOwner) ReconcileConfig { |
| + t.Helper() |
| + cfg := nestedSeedCfg(dirStore(t)) |
| + cfg.Pools = nil |
| + cfg.Seeds.LegacyOwners = func(context.Context) (*seeds.LegacyOwners, error) { |
| + return &seeds.LegacyOwners{V: 1, Names: owners}, nil |
| + } |
| + return cfg |
| +} |
| + |
| +func seedState(t *testing.T, s release.Store, rec Record) *SeedSource { |
| + t.Helper() |
| + r, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return r.Seed |
| +} |
| + |
| +// wantUntouched asserts a deferred swap: the record still ready with no |
| +// reason, the stored row exactly row, and no journal. |
| +func wantUntouched(t *testing.T, s release.Store, reg *registry.Registry, rec Record, row *registry.Route) { |
| + t.Helper() |
| + if got := seedState(t, s, rec); got.State != seeds.StateReady || got.Reason != "" { |
| + t.Fatalf("seed = %s %q, want ready (deferred, not refused)", got.State, got.Reason) |
| + } |
| + got, err := reg.Get(rec.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if row == nil { |
| + if got != nil { |
| + t.Fatalf("a row appeared: %+v", got) |
| + } |
| + } else if got == nil || !reflect.DeepEqual(*got, *row) { |
| + t.Fatalf("row = %+v, want it unchanged %+v", got, row) |
| + } |
| + if j, _ := reg.ImportJournal(rec.Host); j != nil { |
| + t.Fatalf("a deferred import left a journal row: %+v", j) |
| + } |
| +} |
| + |
| +func TestSwapReplacesTheAddrRowAndJournalsIt(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + original := mustGet(t, reg, rec.Host) |
| + cfg := importCfg(t, nil) |
| + at := time.Unix(1_800_000_000, 0) |
| + cfg.Now = func() time.Time { return at } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := mustGet(t, reg, rec.Host) |
| + if got.Kind != "instance" || got.Socket != NestedSocket(rec.Host) || got.Addr != "" || got.Addrs != "" || |
| + got.ImportGate != rec.Seed.VerifyHash || got.DBPath != NestedDBPath("/data", rec.Host) || got.Account != sqid || |
| + !got.Provisioned || got.Seed != "" || !got.Hibernate { |
| + t.Fatalf("swapped row = %+v", got) |
| + } |
| + if col, _ := seeds.ParseRowSeed(got.SeedSource); col == nil || col.ID != rec.Seed.ID || col.State != seeds.StateSwapped { |
| + t.Fatalf("swapped row's seed column = %+v, want this seed at swapped", col) |
| + } |
| + j, err := reg.ImportJournal(rec.Host) |
| + if err != nil || j == nil || j.SeedID != rec.Seed.ID || j.State != registry.JournalSwapped || !reflect.DeepEqual(j.Original, *original) || |
| + j.RecordKey != RecordKey(rec.Account, rec.App, rec.Host) || j.At != at.Unix() { |
| + t.Fatalf("journal = %+v, %v", j, err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateSwapped { |
| + t.Fatalf("record = %s", st.State) |
| + } |
| + |
| + // The next pass converges the row as an ordinary owned row and leaves |
| + // the gate and the journal alone. |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + again := mustGet(t, reg, rec.Host) |
| + if again.ImportGate != rec.Seed.VerifyHash || again.Socket != got.Socket || again.DBPath != got.DBPath || again.Addr != "" { |
| + t.Fatalf("row after the next pass = %+v", again) |
| + } |
| + if j2, _ := reg.ImportJournal(rec.Host); j2 == nil || !reflect.DeepEqual(*j2, *j) { |
| + t.Fatalf("journal after the next pass = %+v", j2) |
| + } |
| +} |
| + |
| +// A row backed by an addrs set of exactly Replaces is the same address. |
| +func TestSwapAcceptsAnAddrsRowOfExactlyReplaces(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + row := addrRow(rec, sqid) |
| + row.Addr, row.Addrs = "", "127.0.0.1:8401" |
| + reg := regWith(t, row) |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, rec.Host); got.ImportGate != rec.Seed.VerifyHash || got.Kind != "instance" { |
| + t.Fatalf("row = %+v", got) |
| + } |
| +} |
| + |
| +func TestSwapRefusesEachCondition(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + row func(Record, string) *registry.Route |
| + owners map[string]seeds.LegacyOwner |
| + cfg func(*ReconcileConfig) |
| + want string |
| + }{ |
| + {"no row", func(Record, string) *registry.Route { return nil }, nil, nil, "there is no route at a.shop.example.com to replace"}, |
| + {"addr changed", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Addr = "127.0.0.1:9"; return &x }, nil, nil, |
| + "the route's address is 127.0.0.1:9, not 127.0.0.1:8401"}, |
| + {"addrs wider than replaces", func(r Record, a string) *registry.Route { |
| + x := addrRow(r, a) |
| + x.Addr, x.Addrs = "", "127.0.0.1:8401,127.0.0.1:8402" |
| + return &x |
| + }, nil, nil, "the route's address is 127.0.0.1:8401,127.0.0.1:8402, not 127.0.0.1:8401"}, |
| + {"addrs behind a matching addr", func(r Record, a string) *registry.Route { |
| + x := addrRow(r, a) |
| + x.Addrs = "127.0.0.1:9" |
| + return &x |
| + }, nil, nil, "the route's address is 127.0.0.1:9, not 127.0.0.1:8401"}, |
| + {"another app", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.App = "other"; return &x }, nil, nil, |
| + "the route belongs to app other"}, |
| + {"a third account", func(r Record, _ string) *registry.Route { x := addrRow(r, "zzz"); return &x }, nil, nil, |
| + "the route is filed under another account"}, |
| + {"operator row, no assignment", func(r Record, _ string) *registry.Route { x := addrRow(r, release.OpsAccount); return &x }, nil, nil, |
| + "the route is filed under the operator account, and no assignment maps shop to this app"}, |
| + {"legacy operator row, no assignment", func(r Record, _ string) *registry.Route { x := addrRow(r, ""); return &x }, nil, nil, |
| + "no assignment maps shop to this app"}, |
| + {"operator row, assigned to another account", func(r Record, _ string) *registry.Route { x := addrRow(r, release.OpsAccount); return &x }, |
| + map[string]seeds.LegacyOwner{"shop": {Account: "other", App: "shop"}}, nil, "no assignment maps shop to this app"}, |
| + {"operator row, assigned to another app", func(r Record, _ string) *registry.Route { x := addrRow(r, release.OpsAccount); return &x }, |
| + map[string]seeds.LegacyOwner{"shop": {Account: "SELF", App: "store"}}, nil, "no assignment maps shop to this app"}, |
| + {"socket backed", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Socket = "/run/x.sock"; return &x }, nil, nil, "(socket)"}, |
| + {"no address", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Addr = ""; return &x }, nil, nil, "(no address)"}, |
| + {"provisioned", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Provisioned = true; return &x }, nil, nil, "(provisioned)"}, |
| + {"static", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Kind = "static"; return &x }, nil, nil, "(kind static)"}, |
| + {"backing", func(r Record, a string) *registry.Route { |
| + x := addrRow(r, a) |
| + x.Backing = registry.BackingUnit |
| + return &x |
| + }, nil, nil, "(backing)"}, |
| + {"tunnel", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Tunnel = "t"; return &x }, nil, nil, "(tunnel)"}, |
| + {"caps", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Caps = "host-role"; return &x }, nil, nil, "(caps)"}, |
| + {"platform", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Platform = true; return &x }, nil, nil, "(platform)"}, |
| + {"canary", func(r Record, a string) *registry.Route { x := addrRow(r, a); x.Canary = true; return &x }, nil, nil, "(canary)"}, |
| + {"flat", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { c.Layout = nil }, |
| + "this box does not run instances nested"}, |
| + {"flat intent", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { |
| + c.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil } |
| + }, "this box does not run instances nested"}, |
| + {"claimed", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { c.ClaimEnabled = true }, |
| + "claim control is on for this box"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + var reg *registry.Registry |
| + var before *registry.Route |
| + if row := c.row(rec, sqid); row != nil { |
| + reg = regWith(t, *row) |
| + before = mustGet(t, reg, rec.Host) |
| + } else { |
| + reg = regWith(t) |
| + } |
| + owners := c.owners |
| + for k, v := range owners { // "SELF" is the record's own account |
| + if v.Account == "SELF" { |
| + owners[k] = seeds.LegacyOwner{Account: sqid, App: v.App} |
| + } |
| + } |
| + cfg := importCfg(t, owners) |
| + if c.cfg != nil { |
| + c.cfg(&cfg) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateRefused || !strings.Contains(st.Reason, c.want) { |
| + t.Fatalf("seed = %s %q, want refused with %q", st.State, st.Reason, c.want) |
| + } |
| + if j, _ := reg.ImportJournal(rec.Host); j != nil { |
| + t.Fatalf("a refused import left a journal row: %+v", j) |
| + } |
| + after, _ := reg.Get(rec.Host) |
| + if (before == nil) != (after == nil) || (before != nil && !reflect.DeepEqual(*before, *after)) { |
| + t.Fatalf("a refused import changed the row: %+v -> %+v", before, after) |
| + } |
| + // A refused import is skipped from then on. |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateRefused { |
| + t.Fatalf("seed after the next pass = %s", st.State) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// M5: an operator-filed row ("bab", or a legacy row with no account) is |
| +// accepted only through the assignment, and the new row is filed under |
| +// the record's account. |
| +func TestSwapAcceptsAnAssignedOperatorRow(t *testing.T) { |
| + for _, acct := range []string{release.OpsAccount, ""} { |
| + t.Run("account "+acct, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, acct)) |
| + if err := Reconcile(ctx, s, reg, importCfg(t, map[string]seeds.LegacyOwner{"shop": {Account: sqid, App: "shop"}})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, rec.Host); got.Account != sqid || got.Kind != "instance" || got.ImportGate == "" { |
| + t.Fatalf("row = %+v; want it filed under the record's account, gated", got) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateSwapped { |
| + t.Fatalf("record = %s", st.State) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The assignment is an authority check: an unreadable file or an unwired |
| +// reader defers the swap, never refuses it and never accepts the row. |
| +func TestSwapDefersWhenTheAssignmentCannotBeRead(t *testing.T) { |
| + for name, hook := range map[string]func(context.Context) (*seeds.LegacyOwners, error){ |
| + "read error": func(context.Context) (*seeds.LegacyOwners, error) { return nil, errors.New("store down") }, |
| + "unwired": nil, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, release.OpsAccount)) |
| + row := mustGet(t, reg, rec.Host) |
| + cfg := importCfg(t, nil) |
| + cfg.Seeds.LegacyOwners = hook |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantUntouched(t, s, reg, rec, row) |
| + }) |
| + } |
| +} |
| + |
| +// M3: the swap is skipped, the record left as it is, unless the verify |
| +// hash is a hash, the host and seed id pass seeds.Check, and the new row |
| +// is nested and hibernating. |
| +func TestSwapSkipsARecordItCannotGate(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + edit func(*Record) |
| + }{ |
| + {"empty verify hash", func(r *Record) { r.Seed.VerifyHash = "" }}, |
| + {"verify hash not a hash", func(r *Record) { r.Seed.VerifyHash = "closed" }}, |
| + {"verify hash in capitals", func(r *Record) { r.Seed.VerifyHash = strings.Repeat("B", 64) }}, |
| + {"bad seed id", func(r *Record) { r.Seed.ID = "sd_../../x" }}, |
| + {"bad host", func(r *Record) { r.Host = "A.shop.example.com/.." }}, |
| + {"does not hibernate", func(r *Record) { off := false; r.Hibernate = &off }}, |
| + {"not an instance", func(r *Record) { r.Kind = "service" }}, |
| + {"proxies to an address", func(r *Record) { r.Addr = "127.0.0.1:9000" }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + stored := importRecord(t, s, sqid) |
| + rec := stored |
| + seed := *stored.Seed |
| + rec.Seed = &seed |
| + c.edit(&rec) |
| + reg := regWith(t, addrRow(stored, sqid)) |
| + row := mustGet(t, reg, stored.Host) |
| + cfg := importCfg(t, nil) |
| + got, _ := reg.Get(stored.Host) |
| + swapImport(ctx, s, reg, cfg, rec, got, mintLayoutReader(ctx, s, cfg)) |
| + wantUntouched(t, s, reg, stored, row) |
| + }) |
| + } |
| +} |
| + |
| +// The same rule through Reconcile, for a record shape CreateRecord stores. |
| +func TestReconcileDoesNotSwapAnImportThatWouldNotHibernate(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := newImport(sqid) |
| + off := false |
| + rec.Hibernate = &off |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + row := mustGet(t, reg, rec.Host) |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantUntouched(t, s, reg, rec, row) |
| +} |
| + |
| +// M4: a layout read error or a NestedOK error defers to the next pass and |
| +// writes nothing; only a layout intent that says flat refuses (above). |
| +func TestSwapDefersATransientLayoutAnswer(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + cfg func(*ReconcileConfig) |
| + }{ |
| + {"layout read error", func(c *ReconcileConfig) { |
| + c.Layout = func(context.Context, release.Store) (string, error) { return "", errors.New("store down") } |
| + }}, |
| + {"nested probe error", func(c *ReconcileConfig) { |
| + c.Seeds.NestedOK = func() error { return errors.New("ready probe timed out") } |
| + }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + row := mustGet(t, reg, rec.Host) |
| + cfg := importCfg(t, nil) |
| + c.cfg(&cfg) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantUntouched(t, s, reg, rec, row) |
| + |
| + // The next pass, with the box answering, swaps. |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateSwapped { |
| + t.Fatalf("record after the box answered = %s %q", st.State, st.Reason) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestSwapRunsWhenTheNestedProbeAnswers(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + cfg := importCfg(t, nil) |
| + asked := 0 |
| + cfg.Seeds.NestedOK = func() error { asked++; return nil } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if asked != 1 || mustGet(t, reg, rec.Host).ImportGate == "" { |
| + t.Fatalf("probe asked %d times, row %+v", asked, mustGet(t, reg, rec.Host)) |
| + } |
| +} |
| + |
| +// Transient refusals of the write itself defer: the config gate, a row |
| +// held by a running operation, and a dry run. |
| +func TestSwapDefersWhatTheWriteCannotTakeYet(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + held bool |
| + cfg func(*ReconcileConfig) |
| + }{ |
| + {"config gate", false, func(c *ReconcileConfig) { |
| + c.ConfigGate = func(context.Context, registry.Route) error { return errors.New("no key yet") } |
| + }}, |
| + {"held", true, nil}, |
| + {"dry run", false, func(c *ReconcileConfig) { c.DryRun = true }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + if c.held { |
| + if err := reg.SetHold(rec.Host, "moving", "op_1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + row := mustGet(t, reg, rec.Host) |
| + if c.held && row.HoldOp == "" { |
| + t.Fatalf("fixture: the row is not held: %+v", row) |
| + } |
| + cfg := importCfg(t, nil) |
| + if c.cfg != nil { |
| + c.cfg(&cfg) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantUntouched(t, s, reg, rec, row) |
| + }) |
| + } |
| +} |
| + |
| +// A crash between the swap's transaction and the record's CAS: the |
| +// journal names this seed and the record still says ready. The next pass |
| +// sees the journal first, never judges the row it made as a legacy row, |
| +// and only redoes the CAS. |
| +func TestSwapResumesTheRecordCASAfterACrash(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + original := mustGet(t, reg, rec.Host) |
| + next := routeFor(rec, "/data", "", true) |
| + next.ImportGate = rec.Seed.VerifyHash |
| + if err := reg.SwapForImport(registry.ImportJournal{Host: rec.Host, SeedID: rec.Seed.ID, Original: *original, |
| + RecordKey: RecordKey(rec.Account, rec.App, rec.Host), At: 1}, next); err != nil { |
| + t.Fatal(err) |
| + } |
| + swapped := mustGet(t, reg, rec.Host) |
| + j, _ := reg.ImportJournal(rec.Host) |
| + |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateSwapped || st.Reason != "" { |
| + t.Fatalf("record = %s %q, want swapped", st.State, st.Reason) |
| + } |
| + if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *swapped) { |
| + t.Fatalf("row changed on the resume: %+v -> %+v", swapped, got) |
| + } |
| + if j2, _ := reg.ImportJournal(rec.Host); j2 == nil || !reflect.DeepEqual(*j2, *j) { |
| + t.Fatalf("journal changed on the resume: %+v -> %+v", j, j2) |
| + } |
| +} |
| + |
| +// A journal row this record's seed does not own (another seed's, or one |
| +// already rolling back) leaves the record alone: no refusal, no swap. |
| +func TestSwapLeavesAnotherSeedsJournalAlone(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + original := mustGet(t, reg, rec.Host) |
| + other, _ := seeds.NewID() |
| + next := routeFor(rec, "/data", "", true) |
| + next.ImportGate = rec.Seed.VerifyHash |
| + if err := reg.SwapForImport(registry.ImportJournal{Host: rec.Host, SeedID: other, Original: *original, At: 1}, next); err != nil { |
| + t.Fatal(err) |
| + } |
| + row := mustGet(t, reg, rec.Host) |
| + j, _ := reg.ImportJournal(rec.Host) |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateReady || st.Reason != "" { |
| + t.Fatalf("record = %s %q, want ready", st.State, st.Reason) |
| + } |
| + if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *row) { |
| + t.Fatalf("row changed: %+v", got) |
| + } |
| + if j2, _ := reg.ImportJournal(rec.Host); !reflect.DeepEqual(j2, j) { |
| + t.Fatalf("journal changed: %+v", j2) |
| + } |
| +} |
| + |
| +// M2: before committed, an import's row is made only by the swap. A box |
| +// with no row never mints one for a swapped or placed import, and neither |
| +// does the swapping box after its gated row was removed. |
| +func TestReconcileNeverMintsAnUncommittedImport(t *testing.T) { |
| + for _, state := range []string{seeds.StateSwapped, seeds.StatePlaced} { |
| + t.Run("no row, "+state, func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := newImport(sqid) |
| + rec.Seed.State = state |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ := reg.Get(rec.Host); rt != nil { |
| + t.Fatalf("minted %+v", rt) |
| + } |
| + }) |
| + } |
| + t.Run("after the gated row was removed", func(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := importRecord(t, s, sqid) |
| + reg := regWith(t, addrRow(rec, sqid)) |
| + cfg := importCfg(t, nil) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := seedState(t, s, rec); st.State != seeds.StateSwapped { |
| + t.Fatalf("record = %s", st.State) |
| + } |
| + if err := reg.Delete(rec.Host); err != nil { // carlos remove |
| + t.Fatal(err) |
| + } |
| + for _, mut := range []func(*Record) error{nil, SeedPlace(rec.Seed.ID)} { |
| + if mut != nil { |
| + if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ := reg.Get(rec.Host); rt != nil { |
| + t.Fatalf("%s: minted %+v", seedState(t, s, rec).State, rt) |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// After commit the import is an ordinary instance: a box with no row |
| +// mints it (ungated), as any other record. |
| +func TestReconcileMintsACommittedImport(t *testing.T) { |
| + s, sqid := shopSetup(t) |
| + rec := newImport(sqid) |
| + rec.Seed.State = seeds.StateCommitted |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, rec.Host); got.ImportGate != "" || got.DBPath != NestedDBPath("/data", rec.Host) { |
| + t.Fatalf("row = %+v", got) |
| + } |
| +} |
| + |
| +func TestImportRefusalOrder(t *testing.T) { |
| + rec := newImport("abc") |
| + row := addrRow(rec, "zzz") |
| + row.App = "other" |
| + cfg := ReconcileConfig{ClaimEnabled: true} |
| + if got := importRefusal(rec, &row, cfg, nil, false); got != "claim control is on for this box" { |
| + t.Fatalf("claims first: %q", got) |
| + } |
| + if got := importRefusal(rec, &row, ReconcileConfig{}, nil, false); got != "this box does not run instances nested" { |
| + t.Fatalf("then layout: %q", got) |
| + } |
| + if got := importRefusal(rec, nil, ReconcileConfig{}, nil, true); got != "there is no route at a.shop.example.com to replace" { |
| + t.Fatalf("then the row: %q", got) |
| + } |
| + if got := importRefusal(rec, &row, ReconcileConfig{}, nil, true); got != "the route belongs to app other" { |
| + t.Fatalf("app before account: %q", got) |
| + } |
| + ok := addrRow(rec, "abc") |
| + if got := importRefusal(rec, &ok, ReconcileConfig{}, nil, true); got != "" { |
| + t.Fatalf("the record's own row: %q", got) |
| + } |
| +} |