package registry import ( "context" "errors" "path/filepath" "reflect" "testing" "time" ) // Neutral fixtures: app shop, hosts under shop.example.com. "bab" is the // pinned ops account (Sqid(1)); "bbw" is Sqid(42), an ordinary account. const ( importHost = "old.shop.example.com" importSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" importRecordKey = "apps/bbw/shop/instances/old.shop.example.com.json" ) func legacyRow() Route { return Route{Host: importHost, Kind: "service", Addr: "127.0.0.1:8401", Status: "ready", App: "shop", Account: "bab", Channel: "stable", Unit: "shop@old.service", Caps: "", CreatedAt: 1700000000} } func execRow() Route { return Route{Host: importHost, Kind: "instance", Socket: "/run/carlos/instances/old/app.sock", DBPath: "/data/instances/old/old.shop.example.com.db", Status: "ready", App: "shop", Account: "bbw", Channel: "stable", Hibernate: true, Provisioned: true, ImportGate: "abc123", CreatedAt: 1700000001} } // swapRow is execRow as the swap writes it: its seed column names the // journal's seed (IsImportRow). func swapRow() Route { r := execRow() r.SeedSource = `{"id":"` + importSeedID + `","kind":"import","state":"swapped"}` return r } func mustJournal(t *testing.T, reg *Registry, host string) *ImportJournal { t.Helper() j, err := reg.ImportJournal(host) if err != nil { t.Fatal(err) } return j } func mustDigest(t *testing.T, f func() (string, error)) string { t.Helper() d, err := f() if err != nil { t.Fatal(err) } return d } func TestSwapCommitAndRestore(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored, At: 1} if err := reg.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } got := mustRoute(t, reg, old.Host) if got.Kind != "instance" || got.ImportGate != "abc123" || got.Account != "bbw" { t.Fatalf("after swap: %+v", got) } jr := mustJournal(t, reg, old.Host) if jr == nil || jr.State != JournalSwapped || !reflect.DeepEqual(jr.Original, stored) || jr.RecordKey != j.RecordKey || jr.At != 1 { t.Fatalf("journal = %+v", jr) } // A second swap for the host is refused: the journal is there. if err := reg.SwapForImport(j, swapRow()); !errors.Is(err, ErrImportJournal) { t.Fatalf("second swap: %v", err) } // Close the gate, restore the route exactly, and the journal says where it is. if err := reg.CloseImportGate(old.Host, j.SeedID, 77); err != nil { t.Fatal(err) } if got := mustRoute(t, reg, old.Host); got.ImportGate != ImportGateClosed { t.Fatalf("gate = %q, want closed", got.ImportGate) } // Commit is only from swapped: a rolling-back import cannot commit. if err := reg.CommitImport(old.Host, j.SeedID); !errors.Is(err, ErrImportJournal) { t.Fatalf("commit while rolling back: %v", err) } // Closing again resumes (idempotent) and keeps the stored generation: // step 2 recognises its own fence by it. if err := reg.CloseImportGate(old.Host, j.SeedID, 78); err != nil { t.Fatal(err) } if jr := mustJournal(t, reg, old.Host); jr == nil || jr.State != JournalRollingBack || jr.FenceGen != 77 { t.Fatalf("journal after a second close = %+v", jr) } if got := mustRoute(t, reg, old.Host); got.ImportGate != ImportGateClosed { t.Fatalf("gate after a second close = %q", got.ImportGate) } if err := reg.RestoreImportRoute(old.Host, j.SeedID); err != nil { t.Fatal(err) } if got := mustRoute(t, reg, old.Host); !reflect.DeepEqual(got, stored) { t.Fatalf("restored %+v, want %+v", got, stored) } if jr := mustJournal(t, reg, old.Host); jr == nil || jr.State != JournalCleanupPending || jr.FenceGen != 77 { t.Fatalf("journal after restore = %+v", jr) } // cleanup-pending never goes back: no second restore, no gate close. if err := reg.RestoreImportRoute(old.Host, j.SeedID); !errors.Is(err, ErrImportJournal) { t.Fatalf("restore from cleanup-pending: %v", err) } if err := reg.CloseImportGate(old.Host, j.SeedID, 78); !errors.Is(err, ErrImportJournal) { t.Fatalf("close from cleanup-pending: %v", err) } if err := reg.DeleteImportJournal(old.Host, "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"); !errors.Is(err, ErrImportJournal) { t.Fatalf("delete with another id: %v", err) } if err := reg.DeleteImportJournal(old.Host, j.SeedID); err != nil { t.Fatal(err) } if jr := mustJournal(t, reg, old.Host); jr != nil { t.Fatalf("journal after delete = %+v", jr) } if err := reg.DeleteImportJournal(old.Host, j.SeedID); !errors.Is(err, ErrImportJournal) { t.Fatalf("delete with no row: %v", err) } } func TestCommitImportClearsTheGateAndTheJournal(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored} if err := reg.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } if err := reg.CommitImport(old.Host, "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"); !errors.Is(err, ErrImportJournal) { t.Fatalf("commit with another id: %v", err) } before := mustDigest(t, reg.Fingerprint) if err := reg.CommitImport(old.Host, j.SeedID); err != nil { t.Fatal(err) } got := mustRoute(t, reg, old.Host) jr := mustJournal(t, reg, old.Host) after := mustDigest(t, reg.Fingerprint) if got.ImportGate != "" || jr != nil || before == after { t.Fatalf("after commit: gate %q journal %+v fingerprint moved %v", got.ImportGate, jr, before != after) } // The committed row is the exec row, ungated. want := swapRow() want.ImportGate = "" if !reflect.DeepEqual(got, want) { t.Fatalf("committed row %+v, want %+v", got, want) } } func TestSwapForImportRefusesAChangedRow(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) changed := stored changed.Addr = "127.0.0.1:9999" if err := reg.Upsert(changed); err != nil { t.Fatal(err) } j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored} if err := reg.SwapForImport(j, swapRow()); !errors.Is(err, ErrImportJournal) { t.Fatalf("swap over a changed row: %v", err) } if got := mustRoute(t, reg, old.Host); got.Addr != "127.0.0.1:9999" { t.Fatal("the changed row was replaced") } if jr := mustJournal(t, reg, old.Host); jr != nil { t.Fatalf("a refused swap left a journal row: %+v", jr) } } func TestSwapForImportRefusesAMissingRowAndAMalformedSwap(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: old} if err := reg.SwapForImport(j, swapRow()); !errors.Is(err, ErrImportJournal) { t.Fatalf("swap with no stored row: %v", err) } if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) j.Original = stored ungated := swapRow() ungated.ImportGate = "" other := swapRow() other.Host = "new.shop.example.com" noID := j noID.SeedID = "" noKey := j noKey.RecordKey = "" otherSeed := swapRow() otherSeed.SeedSource = `{"id":"sd_bbbbbbbbbbbbbbbbbbbbbbbbbb","kind":"import","state":"swapped"}` unprovisioned := swapRow() unprovisioned.Provisioned = false for name, c := range map[string]struct { j ImportJournal next Route }{ "an ungated row": {j, ungated}, "another host": {j, other}, "no seed id": {noID, swapRow()}, "no record key (N1)": {noKey, swapRow()}, "a row naming another seed": {j, otherSeed}, "a row with no seed column": {j, execRow()}, "a row no record provisioned": {j, unprovisioned}, } { if err := reg.SwapForImport(c.j, c.next); !errors.Is(err, ErrImportJournal) { t.Errorf("%s: %v", name, err) } } if got := mustRoute(t, reg, old.Host); !reflect.DeepEqual(got, stored) { t.Fatalf("a refused swap changed the row: %+v", got) } if js, err := reg.ImportJournals(); err != nil || len(js) != 0 { t.Fatalf("journals = %+v, %v", js, err) } } func TestUpsertNeverChangesTheImportGate(t *testing.T) { reg := openTestRegistry(t) row := execRow() if err := reg.Upsert(row); err != nil { t.Fatal(err) } row.ImportGate = "" if err := reg.Upsert(row); err != nil { t.Fatal(err) } if got := mustRoute(t, reg, row.Host); got.ImportGate != "abc123" { t.Fatalf("an Upsert cleared the gate: %q", got.ImportGate) } row.ImportGate = "def456" if err := reg.Upsert(row); err != nil { t.Fatal(err) } if got := mustRoute(t, reg, row.Host); got.ImportGate != "abc123" { t.Fatalf("an Upsert changed the gate: %q", got.ImportGate) } } func TestImportJournalsListsEveryRow(t *testing.T) { reg := openTestRegistry(t) for i, host := range []string{"b.shop.example.com", "a.shop.example.com"} { old := legacyRow() old.Host = host if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, host) next := swapRow() next.Host = host j := ImportJournal{Host: host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored, At: int64(i + 1)} if err := reg.SwapForImport(j, next); err != nil { t.Fatal(err) } } js, err := reg.ImportJournals() if err != nil { t.Fatal(err) } if len(js) != 2 || js[0].Host != "a.shop.example.com" || js[1].Host != "b.shop.example.com" || js[0].Original.Host != "a.shop.example.com" || js[0].At != 2 || js[1].State != JournalSwapped { t.Fatalf("journals = %+v", js) } } // The gate moves both digests, and an ungated registry digests as it did // before the column existed (the goldens in hold_test.go cover that half). func TestImportGateMovesBothFingerprints(t *testing.T) { reg := openTestRegistry(t) if err := reg.Upsert(legacyRow()); err != nil { t.Fatal(err) } fp1 := mustDigest(t, reg.Fingerprint) sfp1 := mustDigest(t, reg.SnapshotFingerprint) if _, err := reg.sql.Exec(`UPDATE routes SET import_gate = 'abc123' WHERE host = ?`, importHost); err != nil { t.Fatal(err) } fp2 := mustDigest(t, reg.Fingerprint) sfp2 := mustDigest(t, reg.SnapshotFingerprint) if fp1 == fp2 || sfp1 == sfp2 { t.Fatal("setting the gate did not move both fingerprints") } if _, err := reg.sql.Exec(`UPDATE routes SET import_gate = ? WHERE host = ?`, ImportGateClosed, importHost); err != nil { t.Fatal(err) } if fp3 := mustDigest(t, reg.Fingerprint); fp3 == fp2 { t.Fatal("closing the gate did not move the edge's fingerprint") } if _, err := reg.sql.Exec(`UPDATE routes SET import_gate = '' WHERE host = ?`, importHost); err != nil { t.Fatal(err) } fp4 := mustDigest(t, reg.Fingerprint) sfp4 := mustDigest(t, reg.SnapshotFingerprint) if fp4 != fp1 || sfp4 != sfp1 { t.Fatal("clearing the gate did not restore the ungated digests") } } // A registry from before the column gains it on open, every time. func TestImportGateMigrationOnALegacyDB(t *testing.T) { path := filepath.Join(t.TempDir(), "registry.db") r, err := Open(path) if err != nil { t.Fatal(err) } if err := r.Upsert(legacyRow()); err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`ALTER TABLE routes DROP COLUMN import_gate`); err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`DROP TABLE import_journal`); err != nil { t.Fatal(err) } r.Close() for i := 0; i < 2; i++ { r, err = Open(path) if err != nil { t.Fatalf("open %d: %v", i, err) } rt, err := r.Get(importHost) if err != nil || rt == nil || rt.ImportGate != "" { t.Fatalf("open %d: %+v %v", i, rt, err) } if js, err := r.ImportJournals(); err != nil || len(js) != 0 { t.Fatalf("open %d: journals %+v %v", i, js, err) } r.Close() } } // Preflight M6: the cold handover's own INSERT carries import_gate, so a // restored row keeps its gate. func TestRestoreClaimRowsCarriesTheImportGate(t *testing.T) { ctx := context.Background() reg := openTestRegistry(t) owner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} row := execRow() row.ClaimOwnership = owner if err := reg.Upsert(row); err != nil { t.Fatal(err) } old, err := reg.Get(row.Host) if err != nil || old == nil || old.ImportGate != "abc123" { t.Fatalf("stored = %+v, %v", old, err) } targets := []ClaimRow{{Kind: "route", Host: row.Host, Ownership: owner}} if err := reg.RetireClaimRows(ctx, "handover-old", targets, func([]ClaimRowArchive) error { return nil }); err != nil { t.Fatal(err) } next := *old next.ClaimOwnership = ClaimOwnership{"inc", 8, "installation-8", "route-8"} if err := reg.RestoreClaimRows(ctx, "handover", "handover-old", ColdRows{Route: *old}, ColdRows{Route: next}); err != nil { t.Fatal(err) } got, err := reg.Get(row.Host) if err != nil || got == nil { t.Fatalf("restored = %+v, %v", got, err) } if *got != next { t.Fatalf("restored %+v, want %+v", *got, next) } // A replay of the same handover is a success, and the gate is still there. if err := reg.RestoreClaimRows(ctx, "handover", "handover-old", ColdRows{Route: *old}, ColdRows{Route: next}); err != nil { t.Fatal(err) } if got := mustRoute(t, reg, row.Host); got != next || got.ImportGate != "abc123" { t.Fatalf("after replay %+v, want %+v", got, next) } } // Fix round 1: an empty journal digests byte-for-byte as before the journal // was hashed. The golden was produced by this fixture on f20d2c5e, whose // SnapshotFingerprint did not read import_journal. func TestSnapshotFingerprintUnchangedForAnEmptyJournal(t *testing.T) { reg := openTestRegistry(t) if err := reg.Upsert(legacyRow()); err != nil { t.Fatal(err) } gated := execRow() gated.Host = "gated.shop.example.com" if err := reg.Upsert(gated); err != nil { t.Fatal(err) } const want = "2:28920899b7d5e223b887dadf6924ee774b34d8d412f1c58bebdb214c35d82b07" got, err := reg.SnapshotFingerprint() if err != nil { t.Fatal(err) } if got != want { t.Fatalf("SnapshotFingerprint = %s, want %s", got, want) } } // Fix round 1 (review Important 1): the journal is in the snapshot digest, // so a deleted cleanup-pending row cannot linger in the last snapshot and be // restored cold. func TestSnapshotFingerprintSeesTheJournal(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored, At: 1} if err := reg.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } if err := reg.CloseImportGate(old.Host, j.SeedID, 77); err != nil { t.Fatal(err) } if err := reg.RestoreImportRoute(old.Host, j.SeedID); err != nil { t.Fatal(err) } pending := mustDigest(t, reg.SnapshotFingerprint) // A journal-only change moves it too. if _, err := reg.sql.Exec(`UPDATE import_journal SET fence_gen = 78 WHERE host = ?`, old.Host); err != nil { t.Fatal(err) } if mustDigest(t, reg.SnapshotFingerprint) == pending { t.Fatal("a journal-only change did not move SnapshotFingerprint") } if _, err := reg.sql.Exec(`UPDATE import_journal SET fence_gen = 77 WHERE host = ?`, old.Host); err != nil { t.Fatal(err) } if mustDigest(t, reg.SnapshotFingerprint) != pending { t.Fatal("SnapshotFingerprint is not a function of the journal's contents") } routesBefore := mustDigest(t, reg.Fingerprint) if err := reg.DeleteImportJournal(old.Host, j.SeedID); err != nil { t.Fatal(err) } if mustDigest(t, reg.Fingerprint) != routesBefore { t.Fatal("the journal moved the edge's fingerprint") } if mustDigest(t, reg.SnapshotFingerprint) == pending { t.Fatal("deleting the journal row did not move SnapshotFingerprint") } } // Review Minor 1: the swap refuses an original it could not put back as an // ordinary legacy row. func TestSwapForImportRefusesAnOwnedHeldOrGatedOriginal(t *testing.T) { for name, mutate := range map[string]func(*Route){ "claim-owned": func(r *Route) { r.ClaimOwnership = ClaimOwnership{"inc", 7, "installation-7", "route-7"} }, "held": func(r *Route) { r.HoldOp = "op-1" }, "gated": func(r *Route) { r.ImportGate = "abc123" }, } { t.Run(name, func(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() mutate(&old) if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored} if err := reg.SwapForImport(j, swapRow()); !errors.Is(err, ErrImportJournal) { t.Fatalf("swap: %v", err) } if got := mustRoute(t, reg, old.Host); got != stored { t.Fatalf("a refused swap changed the row: %+v", got) } if jr := mustJournal(t, reg, old.Host); jr != nil { t.Fatalf("a refused swap left a journal row: %+v", jr) } }) } } // Review Minor 2: an original the journal's JSON would change (invalid // UTF-8 in a free-text column) is refused, so a restore is always exact. func TestSwapForImportRefusesAnOriginalJSONWouldChange(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() old.LastError = "bad \xff byte" if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) if stored.LastError != old.LastError { t.Skipf("the driver did not keep the raw bytes: %q", stored.LastError) } j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored} if err := reg.SwapForImport(j, swapRow()); !errors.Is(err, ErrImportJournal) { t.Fatalf("swap: %v", err) } if got := mustRoute(t, reg, old.Host); got != stored { t.Fatalf("a refused swap changed the row: %+v", got) } } // Review Minor 6: a failure after the transaction's first write rolls the // whole transaction back. A test-only trigger fails the journal write. func TestJournalTransactionsAreAtomic(t *testing.T) { reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, old.Host) j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored} // The swap: the route DELETE and the exec row INSERT land before the // journal INSERT fails. if _, err := reg.sql.Exec(`CREATE TRIGGER fail_swap BEFORE INSERT ON import_journal BEGIN SELECT RAISE(ABORT, 'injected'); END`); err != nil { t.Fatal(err) } if err := reg.SwapForImport(j, swapRow()); err == nil { t.Fatal("swap with a failing journal write: no error") } if got := mustRoute(t, reg, old.Host); got != stored { t.Fatalf("a failed swap left %+v, want the original", got) } if jr := mustJournal(t, reg, old.Host); jr != nil { t.Fatalf("a failed swap left a journal row: %+v", jr) } if _, err := reg.sql.Exec(`DROP TRIGGER fail_swap`); err != nil { t.Fatal(err) } if err := reg.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } if err := reg.CloseImportGate(old.Host, j.SeedID, 77); err != nil { t.Fatal(err) } closed := mustRoute(t, reg, old.Host) // The restore: the route swap lands before the journal UPDATE fails. if _, err := reg.sql.Exec(`CREATE TRIGGER fail_restore BEFORE UPDATE ON import_journal WHEN NEW.state = 'cleanup-pending' BEGIN SELECT RAISE(ABORT, 'injected'); END`); err != nil { t.Fatal(err) } if err := reg.RestoreImportRoute(old.Host, j.SeedID); err == nil { t.Fatal("restore with a failing journal write: no error") } if got := mustRoute(t, reg, old.Host); got != closed { t.Fatalf("a failed restore left %+v, want the closed exec row", got) } if jr := mustJournal(t, reg, old.Host); jr == nil || jr.State != JournalRollingBack { t.Fatalf("a failed restore left the journal at %+v", jr) } if _, err := reg.sql.Exec(`DROP TRIGGER fail_restore`); err != nil { t.Fatal(err) } if err := reg.RestoreImportRoute(old.Host, j.SeedID); err != nil { t.Fatal(err) } if got := mustRoute(t, reg, old.Host); got != stored { t.Fatalf("restored %+v, want %+v", got, stored) } } // swappedRegistry is a registry where the import has swapped legacyRow for // swapRow, and the original as stored. func swappedRegistry(t *testing.T) (*Registry, Route) { t.Helper() reg := openTestRegistry(t) if err := reg.Upsert(legacyRow()); err != nil { t.Fatal(err) } stored := mustRoute(t, reg, importHost) j := ImportJournal{Host: importHost, SeedID: importSeedID, RecordKey: importRecordKey, Original: stored, At: 1} if err := reg.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } return reg, stored } // Task 5 (a stale journal row from a snapshot): the rollback's two route // transactions act only on the import's own row. A row there that is not // it (a later instance at the host, the original put back by hand, a row // naming another seed or no longer gated) is refused and left exactly as // it is, and the journal does not move. func TestRollbackTransactionsRefuseARowThatIsNotTheImports(t *testing.T) { later := execRow() later.ImportGate = "" later.Account = "bbx" for name, foreign := range map[string]Route{ "a later instance": later, "the original back": legacyRow(), "another seed's row": func() Route { r := swapRow(); r.SeedSource = `{"id":"sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"}`; return r }(), "its gate cleared": func() Route { r := swapRow(); r.ImportGate = ""; return r }(), } { t.Run(name, func(t *testing.T) { for _, closeFirst := range []bool{false, true} { reg, _ := swappedRegistry(t) if closeFirst { if err := reg.CloseImportGate(importHost, importSeedID, 7); err != nil { t.Fatal(err) } } before := mustJournal(t, reg, importHost) if _, err := reg.sql.Exec(`DELETE FROM routes WHERE host = ?`, importHost); err != nil { t.Fatal(err) } if err := reg.Upsert(foreign); err != nil { t.Fatal(err) } placed := mustRoute(t, reg, importHost) if err := reg.CloseImportGate(importHost, importSeedID, 8); !errors.Is(err, ErrImportJournal) { t.Fatalf("close over %s: %v", name, err) } if closeFirst { if err := reg.RestoreImportRoute(importHost, importSeedID); !errors.Is(err, ErrImportJournal) { t.Fatalf("restore over %s: %v", name, err) } } if got := mustRoute(t, reg, importHost); got != placed { t.Fatalf("the row changed: %+v, want %+v", got, placed) } if jr := mustJournal(t, reg, importHost); !reflect.DeepEqual(jr, before) { t.Fatalf("the journal moved: %+v -> %+v", before, jr) } } }) } } // Task 5: an import whose row is gone (something removed it) is closed and // moved to cleanup-pending with no route put back. func TestRollbackTransactionsTolerateAMissingRow(t *testing.T) { reg, _ := swappedRegistry(t) if err := reg.Delete(importHost); err != nil { t.Fatal(err) } if err := reg.CloseImportGate(importHost, importSeedID, 7); err != nil { t.Fatal(err) } if err := reg.RestoreImportRoute(importHost, importSeedID); err != nil { t.Fatal(err) } if rt, err := reg.Get(importHost); err != nil || rt != nil { t.Fatalf("a route came back for a removed row: %+v, %v", rt, err) } if jr := mustJournal(t, reg, importHost); jr == nil || jr.State != JournalCleanupPending || jr.FenceGen != 7 { t.Fatalf("journal = %+v", jr) } } func TestIsImportRow(t *testing.T) { j := ImportJournal{Host: importHost, SeedID: importSeedID} closed := swapRow() closed.ImportGate = ImportGateClosed if !IsImportRow(swapRow(), j) || !IsImportRow(closed, j) { t.Fatal("the swap's row, gated or closed, is not recognised") } for name, mutate := range map[string]func(*Route){ "another host": func(r *Route) { r.Host = "new.shop.example.com" }, "a service": func(r *Route) { r.Kind = "service" }, "not provisioned": func(r *Route) { r.Provisioned = false }, "ungated": func(r *Route) { r.ImportGate = "" }, "no seed column": func(r *Route) { r.SeedSource = "" }, "a bad seed column": func(r *Route) { r.SeedSource = "{" }, "another seed": func(r *Route) { r.SeedSource = `{"id":"sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"}` }, } { r := swapRow() mutate(&r) if IsImportRow(r, j) { t.Errorf("%s: recognised as the import's row", name) } } if IsImportRow(swapRow(), ImportJournal{Host: importHost}) { t.Error("a journal with no seed id recognised a row") } } // Fix round 1, I2: a delete decided from an earlier list removes the row // only while it is still the row listed. A rollback that put the original // (another owner's, with its grants) back in between keeps it. func TestDeleteIfUnchanged(t *testing.T) { reg, stored := swappedRegistry(t) listed := mustRoute(t, reg, importHost) if err := reg.CloseImportGate(importHost, importSeedID, 7); err != nil { t.Fatal(err) } closed := mustRoute(t, reg, importHost) if err := reg.RestoreImportRoute(importHost, importSeedID); err != nil { t.Fatal(err) } for name, stale := range map[string]Route{"the gated import row": listed, "the closed import row": closed} { if ok, err := reg.DeleteIfUnchanged(stale); err != nil || ok { t.Fatalf("%s: deleted %v err %v", name, ok, err) } } if got := mustRoute(t, reg, importHost); got != stored { t.Fatalf("the restored row changed: %+v", got) } for name, mutate := range map[string]func(*Route){ "another account": func(r *Route) { r.Account = "bbw" }, "another app": func(r *Route) { r.App = "other" }, "another kind": func(r *Route) { r.Kind = "instance" }, "provisioned": func(r *Route) { r.Provisioned = true }, "a gate": func(r *Route) { r.ImportGate = ImportGateClosed }, "a seed column": func(r *Route) { r.SeedSource = `{"id":"` + importSeedID + `"}` }, "another creation": func(r *Route) { r.CreatedAt++ }, } { stale := stored mutate(&stale) if ok, err := reg.DeleteIfUnchanged(stale); err != nil || ok { t.Fatalf("%s: deleted %v err %v", name, ok, err) } } if ok, err := reg.DeleteIfUnchanged(stored); err != nil || !ok { t.Fatalf("the row as listed: deleted %v err %v", ok, err) } if rt, _ := reg.Get(importHost); rt != nil { t.Fatalf("still there: %+v", rt) } if ok, err := reg.DeleteIfUnchanged(stored); err != nil || ok { t.Fatalf("a second delete: %v %v", ok, err) } } // The merge of the seeded columns with main's good-build, serving, settings // and build-state columns: a registry written by either side alone gains // the other side's columns and tables on open, every time, keeps the row it // held, and then round-trips a row that sets all of them. func TestARegistryFromEitherSideGainsTheOthersColumns(t *testing.T) { seeded := []string{"seed_source", "import_gate"} mains := []string{"good_version", "good_digest", "good_demand", "serving_version", "update_settings", "build_state"} for _, tc := range []struct { name string drop []string // the columns the side that wrote the file never had tables []string // and its tables }{ {"a seeded-side file", mains, nil}, {"a main-side file", seeded, []string{"import_journal", "fence_generation"}}, } { t.Run(tc.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "registry.db") r, err := Open(path) if err != nil { t.Fatal(err) } if err := r.Upsert(legacyRow()); err != nil { t.Fatal(err) } kept := mustRoute(t, r, importHost) for _, c := range tc.drop { if _, err := r.sql.Exec(`ALTER TABLE routes DROP COLUMN ` + c); err != nil { t.Fatal(err) } } for _, tb := range tc.tables { if _, err := r.sql.Exec(`DROP TABLE ` + tb); err != nil { t.Fatal(err) } } r.Close() for i := 0; i < 2; i++ { r, err = Open(path) if err != nil { t.Fatalf("open %d: %v", i, err) } if got := mustRoute(t, r, importHost); got != kept { t.Fatalf("open %d: the row it held reads %+v, want %+v", i, got, kept) } if js, err := r.ImportJournals(); err != nil || len(js) != 0 { t.Fatalf("open %d: journals %+v %v", i, js, err) } if g, err := r.NextFenceGeneration(time.Unix(1, 0)); err != nil || g == 0 { t.Fatalf("open %d: fence generation %d, %v", i, g, err) } r.Close() } r, err = Open(path) if err != nil { t.Fatal(err) } defer r.Close() want := swapRow() want.Host = "both.shop.example.com" want.GoodVersion, want.GoodDigest, want.GoodDemand = "good789", "sha256:good", "swap-demand" want.ServingVersion = "serve012" want.UpdateSettings = `{"updates":"manual","channel":"stable"}` want.BuildState = `{"known":true,"state":{"schema":1,"last_executed":"bs345"}}` if want.SeedSource == "" || want.ImportGate == "" { t.Fatal("the fixture's exec row no longer sets the seeded columns") } if err := r.Upsert(want); err != nil { t.Fatal(err) } got := mustRoute(t, r, want.Host) want.CreatedAt, want.Status, want.Kind = got.CreatedAt, got.Status, got.Kind if got != want { t.Fatalf("round trip after the migration:\n got %+v\n want %+v", got, want) } }) } } // Merge review S1: the journal keeps what the host's own env file held // before the import (EnvBefore), through every state, so the rollback can // tell the key the import minted from one that predates it. func TestImportJournalKeepsEnvBefore(t *testing.T) { for _, before := range []string{"", EnvBeforeAbsent, EnvBeforeNoKey, EnvBeforeKey} { reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: mustRoute(t, reg, old.Host), At: 1, EnvBefore: before} if err := reg.SwapForImport(j, swapRow()); err != nil { t.Fatalf("%q: %v", before, err) } if got := mustJournal(t, reg, old.Host); got == nil || got.EnvBefore != before { t.Fatalf("after the swap: %+v, want env before %q", got, before) } if err := reg.CloseImportGate(old.Host, j.SeedID, 7); err != nil { t.Fatal(err) } if got := mustJournal(t, reg, old.Host); got.EnvBefore != before || got.State != JournalRollingBack { t.Fatalf("rolling back: %+v, want env before %q", got, before) } if js, err := reg.ImportJournals(); err != nil || len(js) != 1 || js[0].EnvBefore != before { t.Fatalf("ImportJournals = %+v, %v", js, err) } } reg := openTestRegistry(t) old := legacyRow() if err := reg.Upsert(old); err != nil { t.Fatal(err) } j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: mustRoute(t, reg, old.Host), At: 1, EnvBefore: "maybe"} if err := reg.SwapForImport(j, swapRow()); !errors.Is(err, ErrImportJournal) { t.Fatalf("a swap with an env state that is none of the recorded ones: %v", err) } } // A journal table from before env_before gains the column on open, and its // rows read as "nothing recorded". Such a row digests as it did; a // recorded value moves the snapshot digest. func TestImportJournalEnvBeforeMigratesAndIsHashedWhenSet(t *testing.T) { path := filepath.Join(t.TempDir(), "registry.db") r, err := Open(path) if err != nil { t.Fatal(err) } old := legacyRow() if err := r.Upsert(old); err != nil { t.Fatal(err) } j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: mustRoute(t, r, old.Host), At: 1} if err := r.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } unset := mustDigest(t, r.SnapshotFingerprint) if _, err := r.sql.Exec(`ALTER TABLE import_journal DROP COLUMN env_before`); err != nil { t.Fatal(err) } r.Close() for i := 0; i < 2; i++ { if r, err = Open(path); err != nil { t.Fatalf("open %d: %v", i, err) } if got := mustJournal(t, r, old.Host); got == nil || got.EnvBefore != "" || got.SeedID != importSeedID { t.Fatalf("open %d: journal %+v", i, got) } if got := mustDigest(t, r.SnapshotFingerprint); got != unset { t.Fatalf("open %d: a row with nothing recorded digests %s, was %s", i, got, unset) } if i == 0 { r.Close() } } defer r.Close() if _, err := r.sql.Exec(`UPDATE import_journal SET env_before = ? WHERE host = ?`, EnvBeforeAbsent, old.Host); err != nil { t.Fatal(err) } if mustDigest(t, r.SnapshotFingerprint) == unset { t.Fatal("a recorded env state did not move SnapshotFingerprint") } } // Merge review S2: a box says which hosts its import journal holds, for the // claim activation census. The read answers through root's registry child // too, where the journal's other methods refuse, so a collector that runs // as root is not left unable to say. func TestImportJournalHostsLocalAndRemote(t *testing.T) { r, path := remoteTestRegistry(t) if hosts, err := r.ImportJournalHosts(); err != nil || hosts == nil || len(hosts) != 0 { t.Fatalf("an empty journal through the child = %v, %v; want an empty list", hosts, err) } local, err := Open(path) if err != nil { t.Fatal(err) } defer local.Close() old := legacyRow() if err := local.Upsert(old); err != nil { t.Fatal(err) } j := ImportJournal{Host: old.Host, SeedID: importSeedID, RecordKey: importRecordKey, Original: mustRoute(t, local, old.Host), At: 1} if err := local.SwapForImport(j, swapRow()); err != nil { t.Fatal(err) } for name, reg := range map[string]*Registry{"local": local, "through the child": r} { hosts, err := reg.ImportJournalHosts() if err != nil || len(hosts) != 1 || hosts[0] != old.Host { t.Fatalf("%s: ImportJournalHosts = %v, %v", name, hosts, err) } } // The rest of the journal stays the agent's own. if _, err := r.ImportJournals(); !errors.Is(err, errRemoteMethod) { t.Fatalf("ImportJournals through the child = %v, want the remote refusal", err) } }