diff --git a/cmd/mesh-controller/chain_no_table_integration_test.go b/cmd/mesh-controller/chain_no_table_integration_test.go new file mode 100644 index 00000000..0bb3a10a --- /dev/null +++ b/cmd/mesh-controller/chain_no_table_integration_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package main + +import ( + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/link" +) + +// **Acceptance 12 of design 47: no table in the controller's store holds builds, rollouts or assignments** (novox/hq ADR +// 0408, issue 1111). A delivery is driven through the live trigger — a merge heard, its batch cut into a plan, the plan's +// build asked and answered, the assignment made — until a build, a rollout and an assignment exist; they are read from the +// chain, and then the store's own catalog is read: none of the tables of the chain is in it, nor the column that held what +// a machine was sent, nor the function that raised the generation from the assignment table; the table that stays, stays. +func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing.T) { + open := windowed(t) + everyHeadWaits(t) + asked := asksRecorded(t) + ctx := t.Context() + inv := open.inventory + if _, err := assign(ctx, open, "anchor", "notes"); err != nil { + t.Fatal(err) + } + hear(t, open, catalogueMerge("c-notes", "notes", t0), t0.Add(time.Second)) + cutAt(t, open, t0.Add(91*time.Second)) + ws, _ := walks(t, open) + if len(ws) != 1 { + t.Fatalf("%d walk(s) after the cut", len(ws)) + } + rollout := ws[0].ID + asking, err := inv.PlanByID(ctx, rollout) + if err != nil { + t.Fatal(err) + } + for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ { + advancePlans(ctx, open) + if asking, err = inv.PlanByID(ctx, rollout); err != nil { + t.Fatal(err) + } + } + buildID := asking.Modules["notes"].Build + if buildID == "" || len(*asked) != 1 { + t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID) + } + digest := "sha256:" + strings.Repeat("b", 64) + result := outcome(buildID, "modules/notes", "c-notes", "notes", "") + result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}} + if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil { + t.Fatal(err) + } + + // What the live path made is in the chain. + if built, found, err := inv.BuildByID(ctx, buildID); err != nil || !found || built.Commit != "c-notes" { + t.Fatalf("the build is not read from the chain: %+v %v %v", built, found, err) + } + if p, err := inv.PlanByID(ctx, rollout); err != nil || p.ID != rollout { + t.Fatalf("the rollout is not read from the chain: %+v %v", p, err) + } + if on, err := inv.AssignmentsOn(ctx, "anchor"); err != nil { + t.Fatal(err) + } else if _, ok := assignmentOf(on, "notes"); !ok { + t.Fatalf("the assignment is not read from the chain: %+v", on) + } + + // And the catalog of the store holds none of them. + have := map[string]bool{} + for _, row := range inv.QueryForTest(t, `select table_name::text from information_schema.tables where table_schema = 'public'`) { + have[strings.Trim(row[0], `"`)] = true + } + for _, table := range []string{"build", "build_request", "release_plan", "batched_merge", "assignment", "pending_assignment", "chain_moved"} { + if have[table] { + t.Errorf("the table %s is in the controller's store", table) + } + } + for _, table := range []string{"build_gate", "assignment_generation"} { + if !have[table] { + t.Errorf("the table %s, which stays, is gone", table) + } + } + if rows := inv.QueryForTest(t, `select count(*) from information_schema.columns where table_schema = 'public' and column_name = 'sent_builds'`); rows[0][0] != "0" { + t.Errorf("a column sent_builds is in the store: %v", rows) + } + if rows := inv.QueryForTest(t, `select count(*) from pg_proc where proname = 'raise_assignment_generation'`); rows[0][0] != "0" { + t.Errorf("the function raise_assignment_generation is in the store: %v", rows) + } +} diff --git a/internal/inventory/chain_drop_test.go b/internal/inventory/chain_drop_test.go new file mode 100644 index 00000000..80c20517 --- /dev/null +++ b/internal/inventory/chain_drop_test.go @@ -0,0 +1,98 @@ +//go:build integration + +package inventory + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// **Migration 0105 refuses a store whose rows were not moved, and drops the tables of a moved one** (novox/hq ADR 0408 §8, +// issue 1111). The store is built as the mesh holds it before this migration, with an assignment row in the table and no +// record of the move: the migration raises, and nothing it did is kept. Then the move is recorded, a pending assignment +// still open refuses it again, and once it has settled the tables go and build_gate stays. +func TestMigration0105RefusesAStoreWhoseRowsWereNotMovedAndDropsTheTablesOfAMovedOne(t *testing.T) { + inv, _ := ForTestBefore(t, 105) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "ui", Version: "1"}, Source{}); err != nil { + t.Fatal(err) + } + inv.ExecForTest(t, `insert into assignment (node, module) select id, 'ui' from node where name = 'anchor'`) + all, err := Migrations() + if err != nil { + t.Fatal(err) + } + tables := func() map[string]bool { + t.Helper() + out := map[string]bool{} + for _, row := range inv.QueryForTest(t, `select table_name::text from information_schema.tables where table_schema = 'public'`) { + out[strings.Trim(row[0], `"`)] = true + } + return out + } + refused := func(why string) { + t.Helper() + _, err := inv.store.Migrate(ctx, all) + if err == nil || !strings.Contains(err.Error(), why) { + t.Fatalf("the migration was not refused for %q: %v", why, err) + } + if !tables()["assignment"] || !tables()["chain_moved"] { + t.Fatalf("a refused migration kept a drop") + } + if rows := inv.QueryForTest(t, `select count(*) from assignment`); rows[0][0] != "1" { + t.Fatalf("a refused migration lost the assignment: %v", rows) + } + } + + // No move recorded: the rows of assignments were never copied. + refused("assignments") + + // The move recorded for every kind, and a pending assignment still waiting for its build: no mover carries it. + inv.ExecForTest(t, `insert into chain_moved (counts, largest) values ($1::jsonb, $1::jsonb)`, + `{"builds": 0, "rollouts": 0, "assignments": 1}`) + inv.ExecForTest(t, `insert into pending_assignment (node, module, build_id) + select id, 'ui', 'b1' from node where name = 'anchor'`) + refused("pending assignment") + + // Expired and raised as a condition, and the condition neither answered nor cleared: still refused. + inv.ExecForTest(t, `update pending_assignment set state = 'expired', raised_at = now()`) + refused("pending assignment") + + // Its condition cleared: the guard lets the migration through. + inv.ExecForTest(t, `update pending_assignment set cleared_at = now()`) + done, err := inv.store.Migrate(ctx, all) + if err != nil || len(done) != 1 || done[0].Number != 105 { + t.Fatalf("the migration did %v (%v)", done, err) + } + left := tables() + for _, gone := range append(append([]string{}, chainTables...), "chain_moved") { + if left[gone] { + t.Errorf("the table %s is still in the store", gone) + } + } + for _, stays := range []string{"build_gate", "assignment_generation", "node", "module"} { + if !left[stays] { + t.Errorf("the table %s is gone", stays) + } + } + if rows := inv.QueryForTest(t, `select count(*) from information_schema.columns where table_name = 'node' and column_name = 'sent_builds'`); rows[0][0] != "0" { + t.Errorf("node.sent_builds is still a column") + } + if rows := inv.QueryForTest(t, `select count(*) from pg_proc where proname = 'raise_assignment_generation'`); rows[0][0] != "0" { + t.Errorf("the function raise_assignment_generation is still in the store") + } +} + +// A store that never held a row of the chain — a mesh raised after the move — passes the guard with no record of a move. +func TestMigration0105PassesAStoreThatNeverHeldARow(t *testing.T) { + inv, migrate := ForTestBefore(t, 105) + migrate() + if rows := inv.QueryForTest(t, `select count(*) from information_schema.tables where table_name in ('build', 'release_plan', 'assignment', 'chain_moved')`); rows[0][0] != "0" { + t.Fatalf("tables of the chain remain: %v", rows) + } +} diff --git a/internal/inventory/migrations/0105-the-chain-leaves-the-store.sql b/internal/inventory/migrations/0105-the-chain-leaves-the-store.sql index e867f97d..61c129f8 100644 --- a/internal/inventory/migrations/0105-the-chain-leaves-the-store.sql +++ b/internal/inventory/migrations/0105-the-chain-leaves-the-store.sql @@ -32,7 +32,7 @@ begin 'run the controller of the previous delivery and read chain_moved back first', array_to_string(missing, ', '); end if; if exists (select 1 from pending_assignment - where state in ('waiting', 'applying') or (raised_at is not null and cleared_at is null)) then + where state in ('waiting', 'applying') or (raised_at is not null and acknowledged_at is null and cleared_at is null)) then raise exception 'a pending assignment is still in the table (waiting, being made, or ended with its condition ' 'unanswered); let it settle, then deliver again'; end if;