Files
mesh-controller/cmd/mesh-controller/planner_rules_test.go
T
jochen add807f034 Say no cycle for a packages edge in a plan's last tier
A packages edge orders nothing, so a module and what packages its source
share a tier by rule; hasCycle counted the edge and the merge handler said
"the last tier depends on itself" of plans with no cycle. Skip the kind as
tiersOf does. The planner tests' cycle rows and property now pass.
2026-10-08 22:22:02 +02:00

449 lines
21 KiB
Go

package main
import (
"fmt"
"math/rand/v2"
"sort"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The delivery planner's rules, as recorded (novox/hq ADR 0162 §1, ADR 0238 §3), held by one table and
// one property over reachOfMerge — the planner's one answer to "what does this merge move, and in which
// order". A row that fails here on main is a planner that breaks a recorded rule: the row stays, the
// expectation is not bent to the code.
//
// The kinds of edge, as the code reads them (release_plan.go):
//
// kind widens the plan orders the tiers
// stands-on yes yes, after its base is built
// declared yes yes, after its base is built
// packages yes no, the same tier (a code dependency)
// built-by no yes, after the build machine — except for what the build machine stands
// on, and for the controller whose worker it binds
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
const (
repoOne = "http://forge.internal:20000/novox/one.git"
repoTwo = "http://forge.internal:20000/novox/two.git"
)
// dep is one edge of the catalogue's relation: from depends on to, in the way kind says.
func dep(from, kind, to string) inventory.Edge {
return inventory.Edge{From: from, To: to, Kind: kind}
}
// tiered is a plan's tiers as one line: a tier's modules by comma, tiers by " | ". Empty for no plan.
func tiered(tiers [][]string) string {
var out []string
for _, t := range tiers {
out = append(out, strings.Join(t, ","))
}
return strings.Join(out, " | ")
}
// planMerge is what reachOfMerge plans for a merge of these files into a repository's main: its tiers as
// one line, and the files no build reads. It also holds the plan to its own shape: every module it builds
// is in exactly one tier.
func planMerge(t *testing.T, repo string, paths []string, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, edges []inventory.Edge) (mergeReach, string) {
t.Helper()
m := link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: "head", Paths: paths}
r := reachOfMerge(m, entries, read, edges)
seen := map[string]int{}
for _, tier := range r.Plan.Tiers {
for _, name := range tier {
seen[name]++
}
}
for name := range r.Plan.Modules {
if seen[name] != 1 {
t.Errorf("%s/%v: %s is in %d tiers of %v", repo, paths, name, seen[name], r.Plan.Tiers)
}
}
if len(seen) != len(r.Plan.Modules) {
t.Errorf("%s/%v: the tiers %v hold modules the plan does not (%d)", repo, paths, r.Plan.Tiers, len(r.Plan.Modules))
}
return r, tiered(r.Plan.Tiers)
}
// **A merge moves the modules whose directory it changed, and everything built on them; nothing else.**
// Each row is a catalogue (modules in directories of one or two repositories), its dependencies with
// their kinds, the files one commit changed, and the exact plan: the moved set and its tier order.
func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
in := func(repo, dir string, names ...string) []inventory.Entry {
var out []inventory.Entry
for _, n := range names {
d := dir + "/" + n
if dir == "" {
d = n
}
out = append(out, fromRepo(n, repo, d))
}
return out
}
// Modules a to f, x and z in novox/one under modules/; g in novox/two at g/.
entries := append(in(repoOne, "modules", "a", "b", "c", "d", "e", "f", "x", "z"), in(repoTwo, "", "g")...)
const (
standsOn = inventory.EdgeStandsOn
declared = inventory.EdgeDeclared
packages = inventory.EdgePackages
builtBy = inventory.EdgeBuiltBy
workerOf = inventory.EdgeWorkerOf
)
// The two real cycles the kinds resolve themselves (ADR 0162 §1, hq issue 206).
runtime := append(in(repoOne, "modules", "runtime", "builder"), fromRepo("controller", repoTwo, ""))
for _, c := range []struct {
what string
entries []inventory.Entry
edges []inventory.Edge
repo string
paths []string
want string // the tiers, " | " between them
unread string
cycle bool
}{
// The operator's case: two modules changed, a third beside them in the same repository untouched,
// each changed one with a dependent.
{what: "A and B changed, C untouched beside them, D on A and E on B",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go", "modules/b/module.json"}, want: "a,b | d,e"},
{what: "only A's directory: A and what stands on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go"}, want: "a | d"},
{what: "only C's directory: C alone, nothing is built on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/c/Dockerfile"}, want: "c"},
{what: "only the dependent changed: its base does not move",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/d/main.go"}, want: "d"},
{what: "transitive: F on D on A, A changed",
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
{what: "transitive across kinds: F declared on D, D packages A",
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "stands-on orders", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "declared (build.on) widens", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "a change to the base alone does not move what it builds", edges: []inventory.Edge{dep("x", builtBy, "a"),
dep("d", builtBy, "a"), dep("e", standsOn, "a")},
repo: "one", paths: []string{"modules/a/module.json"}, want: "a | e"},
// The cycles the kinds resolve: the build machine stands on the runtime image the runtime image is
// built by; the build seat's holder follows the controller that is built by it.
{what: "the build machine's base comes first, built by the build machine that runs", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile", "modules/builder/main.go"}, want: "runtime | builder"},
{what: "the runtime image alone takes the build machine on it along", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile"}, want: "runtime | builder"},
{what: "the build machine alone moves alone", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/builder/main.go"}, want: "builder"},
{what: "the build seat's holder follows the controller it binds the worker of", entries: runtime,
edges: []inventory.Edge{dep("controller", builtBy, "builder"), dep("builder", workerOf, "controller")},
repo: "two", paths: []string{"cmd/main.go"}, want: "controller"},
// Two repositories.
{what: "a dependent in another repository follows its base",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | g"},
{what: "a directory of the same name in another repository is not this one's",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"modules/a/x"}, want: "", unread: "modules/a/x"},
{what: "the dependent's own repository moves the dependent alone",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"g/main.go"}, want: "g"},
// A diamond.
{what: "a diamond, one side changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d"},
{what: "a diamond, both sides changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x", "modules/b/x"}, want: "a,b | d"},
{what: "a diamond on one base", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", declared, "b"),
dep("a", standsOn, "z"), dep("b", standsOn, "z")},
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
{what: "a diamond of mixed kinds orders on the ordering side only",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
// A cycle the catalogue should never produce: what remains is one last tier, and said.
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
dep("f", standsOn, "c")},
repo: "one", paths: []string{"modules/a/x", "modules/c/x"}, want: "c | f | a,b", cycle: true},
// Files no build reads.
{what: "a README at the root of a repository whose modules all live below it",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"README.md"}, want: "", unread: "README.md"},
{what: "a directory no module lives in", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/lib/x.go", "modules/README.md"}, want: "",
unread: "modules/lib/x.go,modules/README.md"},
{what: "a module's directory beside a root file", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"merge-check.sh", "modules/a/x"}, want: "a | d", unread: "merge-check.sh"},
{what: "a directory whose name begins with a module's", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/ab/x"}, want: "", unread: "modules/ab/x"},
} {
e := entries
if c.entries != nil {
e = c.entries
}
r, got := planMerge(t, c.repo, c.paths, e, nil, c.edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
}
if u := strings.Join(r.Unread, ","); u != c.unread {
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
}
// A packages edge in the last tier is no cycle; hasCycle said one on main at 8170fc5.
if hasCycle(r.Plan.Tiers, c.edges) != c.cycle {
t.Errorf("%s: a cycle said %v, wanted %v (%v)", c.what, !c.cycle, c.cycle, r.Plan.Tiers)
}
}
}
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
// built from a shared repository moves on every merge to it) and the decision pending on it would change
// every row here. Today:
//
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
// each a packages edge to every module built from it;
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
// tiers.
//
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
entries := []inventory.Entry{
fromRepo("mesh-controller", controllerRepo, ""),
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
fromRepo("gitea", catalogueRepo, "modules/gitea"),
}
read := map[string][]inventory.ReadRepository{
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
}
edges := sharedRepositoryEdges
for _, c := range []struct {
what, repo string
paths []string
want string
}{
// The live three-tier plan of 2026-10-08 (issue 338). It is also the worker-of order (issue 206)
// that TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case predates: that
// fixture has no worker-of edge and expects the builder first, then the controller and the proxy.
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
{"the controller's own code: the same", "mesh-controller",
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
// In the catalogue, where they live, the rule is path-precise.
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, "build-agent"},
{"another module of the catalogue: neither", "mesh-catalog",
[]string{"modules/gitea/index.ts"}, "gitea"},
} {
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
}
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
}
}
}
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
// sorts them.
var sharedRepositoryEdges = []inventory.Edge{
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
}
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
// and any set of changed files in one repository:
//
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
// for a module built from the root), and everything reachable from them along stands-on, declared and
// packages — never along built-by or worker-of;
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
// depended on in an earlier tier;
// - no cycle is said.
//
// Seeded, so a failure is replayed by its seed and case.
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
// is a prefix of another.
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
files := []string{"README.md", "merge-check.sh", "lib/z.go", "docs/x.md", "modules/README.md"}
falseCycles, firstFalseCycle := 0, ""
for _, seed := range []uint64{1, 2, 3, 0x338, 0x162} {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
for n := 0; n < 100; n++ {
repos := 1 + rng.IntN(3)
repoName := func(i int) string { return fmt.Sprintf("r%d", i) }
count := 1 + rng.IntN(12)
var entries []inventory.Entry
repoOf, dirOf := map[string]int{}, map[string]string{}
for i := 0; i < count; i++ {
name := fmt.Sprintf("m%02d", i)
repo := rng.IntN(repos)
dir := dirs[rng.IntN(len(dirs))]
if rng.IntN(12) == 0 {
dir = "" // built from the repository's root
}
repoOf[name], dirOf[name] = repo, dir
entries = append(entries, fromRepo(name, "http://forge.internal:20000/novox/"+repoName(repo)+".git", dir))
}
// A graph with no cycle: a module depends only on modules made before it.
var edges []inventory.Edge
for i := 1; i < count; i++ {
for j := 0; j < i; j++ {
if rng.IntN(4) == 0 {
edges = append(edges, dep(fmt.Sprintf("m%02d", i), kinds[rng.IntN(len(kinds))], fmt.Sprintf("m%02d", j)))
}
}
}
merged := rng.IntN(repos)
var paths []string
for k := 1 + rng.IntN(4); k > 0; k-- {
if rng.IntN(3) == 0 {
paths = append(paths, files[rng.IntN(len(files))])
} else {
paths = append(paths, dirs[rng.IntN(len(dirs))]+"/f.go")
}
}
// What the rules say.
want := map[string]bool{}
for _, e := range entries {
name := e.Manifest.Module
if repoOf[name] != merged {
continue
}
for _, p := range paths {
if d := dirOf[name]; d == "" || p == d || strings.HasPrefix(p, d+"/") {
want[name] = true
}
}
}
for grew := true; grew; {
grew = false
for _, e := range edges {
if widens[e.Kind] && want[e.To] && !want[e.From] {
want[e.From], grew = true, true
}
}
}
r, _ := planMerge(t, repoName(merged), paths, entries, nil, edges)
got := map[string]bool{}
tierOf := map[string]int{}
for i, tier := range r.Plan.Tiers {
for _, name := range tier {
got[name], tierOf[name] = true, i
}
}
replay := func() string {
return fmt.Sprintf("seed %#x case %d: repository %s, files %v\n modules %v\n edges %v\n tiers %v",
seed, n, repoName(merged), paths, describe(entries), edges, r.Plan.Tiers)
}
if !sameSet(got, want) {
t.Fatalf("planned %v, wanted %v\n%s", keys(got), keys(want), replay())
}
for _, e := range edges {
if orders[e.Kind] && got[e.From] && got[e.To] && tierOf[e.To] >= tierOf[e.From] {
t.Fatalf("%s %s %s, and %s is in tier %d, not before %s's %d\n%s", e.From, e.Kind, e.To,
e.To, tierOf[e.To], e.From, tierOf[e.From], replay())
}
}
if hasCycle(r.Plan.Tiers, edges) {
falseCycles++
if firstFalseCycle == "" {
firstFalseCycle = replay()
}
}
}
}
// Said once, after the other invariants have run over every case, so it hides none of them (hasCycle
// counted a packages edge on main at 8170fc5: 19 of these 500 cases).
if falseCycles > 0 {
t.Errorf("a cycle said of a graph with none in %d of 500 cases; "+
"the first:\n%s", falseCycles, firstFalseCycle)
}
}
func sameSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func describe(entries []inventory.Entry) []string {
var out []string
for _, e := range entries {
out = append(out, fmt.Sprintf("%s@%s:%q", e.Manifest.Module,
strings.TrimSuffix(strings.TrimPrefix(e.Source.Repository, "http://forge.internal:20000/novox/"), ".git"),
e.Source.Path))
}
return out
}