A failed build, or a plan closed before reaching a module, left the closure its last good build said, and a fix-forward to a newly imported package would have moved nothing. A missed merge moving only a module that packages the repository was never caught up, and an older merge read as history for it through a look that was not its own. The gate, a pull request's check, the what-if and a delivery's order now read the same view the merge handler does.
611 lines
30 KiB
Go
611 lines
30 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"},
|
|
{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"},
|
|
|
|
// 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 after the holder that builds it (built-by).
|
|
{"a package all three build from: all three", "mesh-controller",
|
|
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
|
{"a vendored package all three build from: all three", "mesh-controller",
|
|
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
|
{"go.sum: all three", "mesh-controller",
|
|
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
|
{"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 | build-agent | route-proxy", ""},
|
|
// 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 | build-agent | route-proxy" {
|
|
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 := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(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)
|
|
}
|
|
}
|
|
|
|
// 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}
|
|
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
|
|
}
|
|
|
|
// **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)
|
|
}
|
|
}
|