package main import ( "testing" "time" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // A merge produces a tiered plan (novox/hq ADR 0162): what moved and everything reachable from it, // sorted so a tier depends only on earlier ones — with the three kinds of dependency told apart. func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { edges := []inventory.Edge{ // build dependencies: images on the runtime, a plugin on one of them {From: "shop", To: "mesh-tools", Kind: inventory.EdgeStandsOn}, {From: "postgres", To: "mesh-tools", Kind: inventory.EdgeStandsOn}, {From: "shop-plugin", To: "shop", Kind: inventory.EdgeDeclared}, // a code dependency: the proxy packages the controller's source — same tier {From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages}, {From: "builder", To: "mesh-controller", Kind: inventory.EdgePackages}, // runtime dependencies: everything source-built is built by the builder {From: "shop", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "postgres", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "shop-plugin", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "route-proxy", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn}, {From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn}, } // The runtime image moved: everything on it, and what is built by what is on it. set := reachableFrom([]string{"mesh-tools"}, edges) // What stands on the runtime, and the builder that stands on it; not the controller, which the // builder merely builds, nor the proxy that packages the controller. want := []string{"builder", "mesh-tools", "postgres", "shop", "shop-plugin"} if len(set) != len(want) { t.Fatalf("reachable from the runtime: %v, want %v", set, want) } tiers := tiersOf(set, edges) pos := map[string]int{} for i, tier := range tiers { for _, m := range tier { pos[m] = i } } if pos["mesh-tools"] != 0 || pos["builder"] != 1 { t.Fatalf("the runtime then the builder: %v", tiers) } if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) { t.Fatalf("what the builder builds comes after the builder: %v", tiers) } if pos["shop-plugin"] <= pos["shop"] { t.Fatalf("a plugin after what it is declared on: %v", tiers) } if hasCycle(tiers, edges) { t.Fatalf("no cycle here: %v", tiers) } // The controller alone moved: the proxy with it, nothing else. small := reachableFrom([]string{"mesh-controller"}, edges) if len(small) != 3 { t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small) } // The builder packages the controller's source (same tier by that edge) and the controller is // built by the builder (next tier by that one): the builder first, then the controller and the // proxy together — a code dependency in one tier, a runtime dependency across tiers. smallTiers := tiersOf(small, edges) if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 { t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers) } // The builder alone moved: the builder, and nothing it builds. if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 { t.Fatalf("a build machine change rebuilds the build machine alone: %v", only) } // Only a runtime dependency gates on deployment, and only when the module rolls out. p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges) p.Tier = pos["builder"] rollsOut := func(m string) bool { return m == "builder" } if g := gates(p, edges, rollsOut); len(g) != 1 || g[0] != "builder" { t.Fatalf("the builder gates the tier after it: %v", g) } p.Tier = 0 if g := gates(p, edges, rollsOut); len(g) != 0 { t.Fatalf("the runtime image is a build dependency and gates nothing: %v", g) } if g := gates(p, edges, func(string) bool { return false }); len(g) != 0 { t.Fatalf("a module that only records its upgrade gates nothing: %v", g) } } // A gate is open once every machine running the module has reported after it was built. func TestAGateOpensWhenTheMachinesHaveReportedSinceTheBuild(t *testing.T) { built := time.Date(2026, 10, 1, 15, 0, 0, 0, time.UTC) before, after := built.Add(-time.Minute), built.Add(time.Minute) reports := []inventory.Reported{{Node: "anchor", At: &after}, {Node: "home-server", At: &before}} ok, waiting := applied("builder", built, []string{"anchor", "home-server"}, reports) if ok || len(waiting) != 1 || waiting[0] != "home-server" { t.Fatalf("one machine has not reported since the build: ok=%v waiting=%v", ok, waiting) } if ok, _ := applied("builder", built, []string{"anchor"}, reports); !ok { t.Fatal("the machine that reported after the build holds the gate open") } if ok, _ := applied("builder", built, nil, reports); !ok { t.Fatal("a module running nowhere gates nothing") } } // A cycle is not lost: what remains is one last tier, and the caller says so. func TestACycleIsOneLastTierAndSaidSo(t *testing.T) { edges := []inventory.Edge{{From: "a", To: "b", Kind: inventory.EdgeStandsOn}, {From: "b", To: "a", Kind: inventory.EdgeStandsOn}} tiers := tiersOf([]string{"a", "b"}, edges) if len(tiers) != 1 || len(tiers[0]) != 2 || !hasCycle(tiers, edges) { t.Fatalf("a cycle should be one tier of two, said: %v", tiers) } }