The first live plan took seventy-five modules along for a controller change: the builder packages the controller's source, everything is built by the builder, so everything was reachable. A module built by the build machine is not changed by a new build machine. Reachability now follows the code and build edges only; built-by still orders a tier after the build machine and gates it on the machine's roll-out. plans stop <id> ends a plan by hand: what was asked still builds and registers, nothing further is asked.
118 lines
5.5 KiB
Go
118 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|