mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head da8a5e9bc0f7 merged into trunk 76304d9db817 — pass: builds mesh-controller → novox; no bus step; every machine composes with the chang…
mesh/repo-check head da8a5e9bc0f7 merged into trunk 76304d9db817 — fail: its merge-check.sh failed: --- FAIL: TestAReusedCheckBuildIsRegisteredByItsDigestA…
The operator chose the leanest rollout: no gate, no one machine first, failures loud and fixed forward. A walk sends every rolled-out module to every machine running it in one step, built-by orders no tier, and a walk ends on its machines' reports. The backlog walk sends every machine at once and passes each on its applied report.
683 lines
36 KiB
Go
683 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
|
"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 no no — retired by novox/hq ADR 0267; one recorded before is read and ignored
|
|
// 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 stops at a packages edge: F declared on D, D packages A (ADR 0267)",
|
|
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
|
|
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
|
|
|
// 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, recorded before ADR 0267, widens nothing", edges: []inventory.Edge{dep("x", packages, "a")},
|
|
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
|
{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"},
|
|
// Whichever builder runs builds it (novox/hq ADR 0360 §3): built-by orders nothing, as before it ordered.
|
|
{what: "built-by does not order", 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"},
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
|
// issue 338, issue 363). The controller is built from its repository's root as a Go bundle; the route proxy
|
|
// and the build seat's holder build images whose context is that repository and which name the package they
|
|
// compile. Each newest trunk build said its build source — the import closure of its program — and a merge
|
|
// is mapped onto those. The rows tagged 338 held the opposite until ADR 0267 was built: every merge to the
|
|
// controller's repository planned all three, in three tiers.
|
|
//
|
|
// The build sources are this repository's own programs as GoBuildSource reads them (held to that by
|
|
// TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn in internal/builder), cut to what the rows need.
|
|
func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(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"),
|
|
}
|
|
gomod := []string{"go.mod", "go.sum", "vendor/modules.txt"}
|
|
with := func(paths ...string) []string { return append(append([]string{}, gomod...), paths...) }
|
|
read := map[string][]inventory.ReadRepository{
|
|
"mesh-controller": {{Own: true, Paths: with("module.json", "cmd/mesh-controller/", "internal/conditions/",
|
|
"internal/broker/", "internal/builder/", "internal/inventory/", "internal/inventory/migrations/**",
|
|
"vendor/github.com/nats-io/nats.go/")}},
|
|
"build-agent": {
|
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("cmd/mesh-builder/", "internal/broker/",
|
|
"internal/builder/", "internal/inventory/", "internal/inventory/migrations/**", "vendor/github.com/nats-io/nats.go/")},
|
|
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
|
},
|
|
"route-proxy": {
|
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("examples/route-proxy/", "internal/broker/",
|
|
"vendor/github.com/nats-io/nats.go/")},
|
|
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
|
},
|
|
}
|
|
// With no build source said — before each module's first trunk build under ADR 0267, or while an
|
|
// earlier merge's build of it is pending — a module is read as before.
|
|
unsaid := 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
|
|
read map[string][]inventory.ReadRepository
|
|
want string
|
|
unread string
|
|
}{
|
|
// The operator's acceptance: a merge of the controller's own code plans the controller alone.
|
|
{"the controller's own command: the controller alone (338)", "mesh-controller",
|
|
[]string{"cmd/mesh-controller/main.go"}, read, "mesh-controller", ""},
|
|
{"a package only the controller builds from: the controller alone (338)", "mesh-controller",
|
|
[]string{"internal/conditions/condition.go", "internal/conditions/bus.go"}, read, "mesh-controller", ""},
|
|
{"a test beside the controller's command: nothing is built from it", "mesh-controller",
|
|
[]string{"cmd/mesh-controller/main_test.go"}, read, "", "cmd/mesh-controller/main_test.go"},
|
|
{"a README of the controller's repository: no module (338)", "mesh-controller",
|
|
[]string{"README.md"}, read, "", "README.md"},
|
|
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
|
|
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""},
|
|
{"the build seat's program alone: its holder alone", "mesh-controller",
|
|
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""},
|
|
{"a package the build seat's program and the controller build from: both", "mesh-controller",
|
|
[]string{"internal/builder/builder.go"}, read, "mesh-controller | build-agent", ""},
|
|
{"a migration the controller and the build seat's program embed: both", "mesh-controller",
|
|
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
|
|
"mesh-controller | build-agent", ""},
|
|
// A package all three build from: all three; the build seat's holder after the controller whose worker
|
|
// it binds (worker-of, issue 206), the proxy beside the controller: built-by orders nothing (ADR 0360 §3).
|
|
{"a package all three build from: all three", "mesh-controller",
|
|
[]string{"internal/broker/broker.go"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
|
{"a vendored package all three build from: all three", "mesh-controller",
|
|
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
|
{"go.sum: all three", "mesh-controller",
|
|
[]string{"go.sum"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
|
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
|
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
|
|
// Before any build source is said: as before.
|
|
{"no build source said: a README moves all three, as before", "mesh-controller",
|
|
[]string{"README.md"}, unsaid, "mesh-controller,route-proxy | build-agent", ""},
|
|
// In the catalogue, where they live, each by its own build source.
|
|
{"the route proxy's recipe: it alone", "mesh-catalog",
|
|
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
|
|
{"the route proxy's manifest: it alone", "mesh-catalog",
|
|
[]string{"modules/route-proxy/module.json"}, read, "route-proxy", ""},
|
|
{"the route proxy's README: nothing", "mesh-catalog",
|
|
[]string{"modules/route-proxy/README.md"}, read, "", "modules/route-proxy/README.md"},
|
|
{"the build agent's manifest: it alone, nothing it builds", "mesh-catalog",
|
|
[]string{"modules/build-agent/module.json"}, read, "build-agent", ""},
|
|
{"another module of the catalogue: neither", "mesh-catalog",
|
|
[]string{"modules/gitea/index.ts"}, read, "gitea", ""},
|
|
} {
|
|
r, got := planMerge(t, c.repo, c.paths, entries, c.read, 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)
|
|
}
|
|
}
|
|
// Files not all said: everything the repository builds, as before.
|
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
|
|
Paths: []string{"README.md"}, PathsTruncated: true}
|
|
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller,route-proxy | build-agent" {
|
|
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
|
|
}
|
|
}
|
|
|
|
// **A module whose recorded build source a plan has overtaken is read whole** (novox/hq ADR 0267): a merge
|
|
// that added an import to the route proxy is planned; before a build of it works — still building, failed,
|
|
// or its plan closed before reaching it — a merge changing only the newly imported package must still move
|
|
// the proxy, since the build source its last build said does not hold that package.
|
|
func TestAModuleAPlanOvertookIsReadWhole(t *testing.T) {
|
|
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
|
read := map[string][]inventory.ReadRepository{
|
|
"route-proxy": {
|
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}, Built: built},
|
|
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
|
|
},
|
|
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
|
|
}
|
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Paths: []string{"internal/newly/imported.go"}}
|
|
if readsFrom(read["route-proxy"], m) {
|
|
t.Fatal("the said build source holds the new package: the fixture is wrong")
|
|
}
|
|
proxy := func(state string) map[string]*inventory.PlanModule {
|
|
return map[string]*inventory.PlanModule{"route-proxy": {State: state}, "gitea": {State: "built"}}
|
|
}
|
|
for _, c := range []struct {
|
|
what string
|
|
plans []inventory.Plan
|
|
whole bool
|
|
}{
|
|
{"no plan", nil, false},
|
|
{"a plan still building it", []inventory.Plan{{State: inventory.PlanBuilding, Created: built.Add(-time.Hour),
|
|
Modules: proxy("building")}}, true},
|
|
{"a plan made after its build that failed before building it", []inventory.Plan{{State: "failed",
|
|
Created: built.Add(time.Minute), Modules: proxy("waiting")}}, true},
|
|
{"a plan made after its build that built it", []inventory.Plan{{State: inventory.PlanDone,
|
|
Created: built.Add(time.Minute), Modules: proxy("built")}}, false},
|
|
{"a plan closed before its build", []inventory.Plan{{State: "failed", Created: built.Add(-time.Hour),
|
|
Modules: proxy("waiting")}}, false},
|
|
} {
|
|
view := planningView(read, c.plans)
|
|
if readsFrom(view["route-proxy"], m) != c.whole {
|
|
t.Errorf("%s: read whole %v, wanted %v", c.what, !c.whole, c.whole)
|
|
}
|
|
if (ownSource(view["route-proxy"]) == nil) != c.whole {
|
|
t.Errorf("%s: its own build source kept %v", c.what, ownSource(view["route-proxy"]) != nil)
|
|
}
|
|
if ownSource(view["mesh-controller"]) == nil {
|
|
t.Errorf("%s: a module no plan holds lost its build source", c.what)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **A missed merge that moves only a module packaging the repository is acted on** (novox/hq ADR 0267,
|
|
// issue 266): the catch-up asks wouldMove, which counts it; once a plan or build of it is made after the
|
|
// merge, the merge is history for it, and the catch-up leaves it.
|
|
func TestAMissedMergeMovingOnlyAPackagingModuleIsActedOnOnce(t *testing.T) {
|
|
merged := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
|
entries := []inventory.Entry{
|
|
fromRepo("mesh-controller", "http://forge.internal:20000/novox/mesh-controller.git", ""),
|
|
fromRepo("route-proxy", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/route-proxy"),
|
|
}
|
|
read := map[string][]inventory.ReadRepository{
|
|
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
|
|
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"},
|
|
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
|
|
}
|
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
|
|
MergedAt: merged.Format(time.RFC3339), Paths: []string{"examples/route-proxy/main.go"}}
|
|
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
|
|
t.Fatalf("a missed merge of the proxy's program would move %v", got)
|
|
}
|
|
// Acted on at once: the plan answering this very merge is a look, however close the clocks.
|
|
atOnce := []inventory.Plan{{State: inventory.PlanBuilding, Commit: "c1", Created: merged.Add(2 * time.Second),
|
|
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
|
if got := wouldMove(m, entries, planningView(read, atOnce)); len(got) != 0 {
|
|
t.Fatalf("a merge whose own plan holds the proxy would move %v again", got)
|
|
}
|
|
acted := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(2 * time.Minute),
|
|
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
|
if got := wouldMove(m, entries, planningView(read, acted)); len(got) != 0 {
|
|
t.Fatalf("a merge acted on for the proxy would move %v again", got)
|
|
}
|
|
// A plan that closed without building it looked at nothing: the merge is still news for it.
|
|
closed := []inventory.Plan{{State: "failed", Created: merged.Add(2 * time.Minute),
|
|
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "waiting"}}}}
|
|
if got := wouldMove(m, entries, planningView(read, closed)); len(got) != 1 {
|
|
t.Fatalf("a plan that never built the proxy hid the merge from it: %v", got)
|
|
}
|
|
// A look just before the merge, on clocks a little apart, is no look after it.
|
|
skewed := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(30 * time.Second),
|
|
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
|
if got := wouldMove(m, entries, planningView(read, skewed)); len(got) != 1 {
|
|
t.Fatalf("a look within the clocks' margin made the merge history: %v", got)
|
|
}
|
|
}
|
|
|
|
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
|
|
// sorts them: no packages edge (novox/hq ADR 0267 rule 4).
|
|
var sharedRepositoryEdges = []inventory.Edge{
|
|
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"),
|
|
}
|
|
|
|
// **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 and declared —
|
|
// never along packages (novox/hq ADR 0267), 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}
|
|
// Built-by orders nothing since novox/hq ADR 0360 §3.
|
|
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: 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
|
|
}
|
|
|
|
// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a
|
|
// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the
|
|
// gate's plan as it narrows the merge's; one without them reads every module as before.
|
|
func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) {
|
|
manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) }
|
|
facts := snapshot.Facts{Modules: []snapshot.Module{
|
|
{Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"),
|
|
Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}},
|
|
{Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"),
|
|
Reads: []string{"novox/mesh-controller"},
|
|
Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"examples/route-proxy/", "internal/broker/"}},
|
|
{Own: true, Paths: []string{"modules/route-proxy/module.json"}}}},
|
|
}}
|
|
for paths, want := range map[string]string{
|
|
"cmd/mesh-controller/main.go": "mesh-controller",
|
|
"examples/route-proxy/main.go": "route-proxy",
|
|
"internal/broker/broker.go": "mesh-controller,route-proxy",
|
|
"README.md": "",
|
|
} {
|
|
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := tiered(r.Plan.Tiers); got != want {
|
|
t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want)
|
|
}
|
|
}
|
|
// A snapshot without build sources: as before.
|
|
for i := range facts.Modules {
|
|
facts.Modules[i].Sources = nil
|
|
}
|
|
r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" {
|
|
t.Errorf("a snapshot without build sources: the gate planned %q for a README", got)
|
|
}
|
|
}
|
|
|
|
// **A plan says why each module is in it** (novox/hq ADR 0267, issue 363): the files of its build source the
|
|
// merge changed, or why it is read whole — never that it packages a repository as though that moved it.
|
|
func TestAPlanSaysWhyEachModuleIsInIt(t *testing.T) {
|
|
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
|
controller := fromRepo("mesh-controller", controllerRepo, "")
|
|
agent := fromRepo("build-agent", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/build-agent")
|
|
gitea := fromRepo("gitea", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/gitea")
|
|
built := time.Date(2026, 10, 10, 12, 26, 0, 0, time.UTC)
|
|
read := map[string][]inventory.ReadRepository{
|
|
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/link/"}, Built: built}},
|
|
"build-agent": {
|
|
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"cmd/mesh-builder/", "internal/link/"}, Built: built},
|
|
{Own: true, Paths: []string{"modules/build-agent/module.json"}, Built: built},
|
|
},
|
|
}
|
|
merge := func(repo string, paths ...string) link.SourceMoved {
|
|
return link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Paths: paths}
|
|
}
|
|
open := []inventory.Plan{{ID: "plan-1", State: inventory.PlanBuilding, Created: built.Add(time.Minute),
|
|
Modules: map[string]*inventory.PlanModule{"build-agent": {State: "asked"}}}}
|
|
for _, c := range []struct {
|
|
what string
|
|
e inventory.Entry
|
|
read map[string][]inventory.ReadRepository
|
|
m link.SourceMoved
|
|
says string
|
|
}{
|
|
{"the controller's own closure", controller, read, merge("mesh-controller", "README.md", "internal/link/handacts.go"),
|
|
"its build source changed: 1 changed file(s) in it, e.g. internal/link/handacts.go"},
|
|
{"the build seat's closure, through its context", agent, read, merge("mesh-controller", "internal/link/handacts.go"),
|
|
"its build source in novox/mesh-controller changed: 1 changed file(s) in it, e.g. internal/link/handacts.go"},
|
|
{"an open plan has yet to build it", agent, planningView(read, open), merge("mesh-controller", "cmd/mesh-controller/main.go"),
|
|
"read whole: plan plan-1 has not built it yet, so every file of novox/mesh-controller, which its build context is, is its build source"},
|
|
{"nothing recorded, built from its root", controller, nil, merge("mesh-controller", "README.md"),
|
|
"read whole: no build source recorded, so every file of its repository is its build source"},
|
|
{"nothing recorded, in its directory", gitea, nil, merge("mesh-catalog", "modules/gitea/index.ts"),
|
|
"read whole: no build source recorded, so its directory is its build source; e.g. modules/gitea/index.ts"},
|
|
{"a root manifest is not in a module's directory", gitea, nil, merge("mesh-catalog", "module.json", "modules/gitea/x.ts"),
|
|
"read whole: no build source recorded, so its directory is its build source; e.g. modules/gitea/x.ts"},
|
|
{"a context on another branch says nothing of this one", agent, map[string][]inventory.ReadRepository{"build-agent": {
|
|
{Repository: "novox/mesh-controller", Ref: "release", Paths: []string{"internal/link/"}},
|
|
{Repository: "novox/mesh-controller", Ref: "main"}}}, merge("mesh-controller", "internal/link/handacts.go"),
|
|
"read whole: no build source recorded, so every file of novox/mesh-controller, which its build context is, is its build source"},
|
|
{"files not all said", controller, read, link.SourceMoved{Owner: "novox", Repo: "mesh-controller", PathsTruncated: true,
|
|
Paths: []string{"x"}}, "read whole: the merge's changed files were not all said"},
|
|
} {
|
|
if got := whyMoved(c.e, c.read[c.e.Manifest.Module], c.m); got != c.says {
|
|
t.Errorf("%s:\n said %q\n wanted %q", c.what, got, c.says)
|
|
}
|
|
}
|
|
}
|