A merge produces a tiered plan the mesh keeps (hq ADR 0162)
A module's dependencies are one relation in the catalogue — stands-on, packages, built-by, declared — answered by one call. A merge takes what moved and everything reachable from it, sorts the set into tiers (a code dependency in the same tier, a build dependency after its base is built, a runtime dependency after the build machine is built and running; the build machine's own base comes first, built by the one that runs), writes the plan to the store, asks the first tier and returns. Every outcome advances the plan; a ticker advances what outcomes cannot; a controller replaced mid-plan resumes it. status lists open plans and names one that has waited too long.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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},
|
||||
// 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)
|
||||
want := []string{"builder", "mesh-controller", "mesh-tools", "postgres", "route-proxy", "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"] && pos["mesh-controller"] > 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 pos["route-proxy"] != pos["mesh-controller"] {
|
||||
t.Fatalf("a code dependency is rebuilt in the same tier as its source: %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) != 2 {
|
||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
||||
}
|
||||
if tiers := tiersOf(small, edges); len(tiers) != 1 {
|
||||
t.Fatalf("both in one tier: %v", tiers)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user