The gate composed 0 of 4 machines with the change and without, and passed every change: the store it raised held each module's bus credential but no account for it (issue 203's refusal), no outward links (so no filter could be composed), and refused settings the mesh holds. Now the account is minted with its credential, the facts carry each machine's outward links (a stand-in for an older snapshot), the mesh's layers are kept as held, and a withheld path keeps a path's shape. A machine the mesh composes that the gate cannot raise makes the verdict an error, never a pass; the verdict alone is on stdout. A merge-check.sh that passed on an agent's machine failed on the build seat: a newer gofmt, siblings at a feature branch, another user. `mesh-controller check-here` runs builder.Check with the ask the controller would make, from facts that now name the toolchains and the refs cloned beside; a failed script is said by what failed. (novox/hq issues 282, 283)
415 lines
17 KiB
Go
415 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// The merge gate (novox/hq to-be 45 §9): a change judged against every machine of the snapshot, by the
|
|
// incidents it is written from. Each case raises a mesh, takes its snapshot, and judges a pull request's
|
|
// tree against it in throwaway stores of its own.
|
|
|
|
// catalogueMesh is two machines and a catalogue repository: a resolver and an object store on the
|
|
// anchor, and on the laptop a network manager requiring the resolver, an album requiring the object
|
|
// store, and a login manager. Every module is registered from novox/mesh-catalog, as the mesh's are.
|
|
func catalogueMesh(t *testing.T) (snapshot.Facts, map[string]string) {
|
|
t.Helper()
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
manifests := map[string]string{
|
|
"objects": `{"module":"objects","version":"1",
|
|
"provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}],
|
|
"receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`,
|
|
"resolver": `{"module":"resolver","version":"1",
|
|
"provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`,
|
|
"networkmanager": `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`,
|
|
"album": `{"module":"album","version":"1","requires":["s3-bucket"]}`,
|
|
"lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"],
|
|
"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`,
|
|
}
|
|
for name, raw := range manifests {
|
|
m, err := catalogue.ParseManifest([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
|
Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"}, {"laptop", "networkmanager"},
|
|
{"laptop", "album"}, {"laptop", "lemurs"}} {
|
|
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
|
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
|
}
|
|
}
|
|
f, err := gatherFacts(ctx, open, "2.11.17")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f, manifests
|
|
}
|
|
|
|
// aTree is a checkout of the catalogue repository holding these manifests.
|
|
func aTree(t *testing.T, manifests map[string]string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for name, raw := range manifests {
|
|
at := filepath.Join(dir, "modules", name)
|
|
if err := os.MkdirAll(at, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(raw), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func gateJudged(t *testing.T, f snapshot.Facts, tree string, changed ...string) mergeVerdict {
|
|
t.Helper()
|
|
admin := os.Getenv("MESH_TEST_POSTGRES")
|
|
in := mergeCheckInput{facts: f, admin: admin, changed: changed}
|
|
if tree != "" {
|
|
in.repository, in.tree = "novox/mesh-catalog", tree
|
|
}
|
|
v, err := judgeChange(t.Context(), in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func withEdit(manifests map[string]string, name, raw string) map[string]string {
|
|
out := map[string]string{}
|
|
for k, v := range manifests {
|
|
out[k] = v
|
|
}
|
|
if raw == "" {
|
|
delete(out, name)
|
|
} else {
|
|
out[name] = raw
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The mesh as it is passes: every machine composes in the gate's store as the controller composed it.
|
|
func TestTheMeshAsItIsPassesTheGate(t *testing.T) {
|
|
f, manifests := catalogueMesh(t)
|
|
for _, m := range f.Machines {
|
|
if !m.Declaration.Composes {
|
|
t.Fatalf("the mesh itself does not compose: %+v", m.Declaration)
|
|
}
|
|
}
|
|
v := gateJudged(t, f, aTree(t, manifests))
|
|
if v.Verdict != "pass" {
|
|
t.Fatalf("an unchanged catalogue does not pass:\n%s", v.Report())
|
|
}
|
|
for _, m := range v.Machines {
|
|
if !m.Base.Composes || !m.Change.Composes {
|
|
t.Errorf("%s does not compose in the gate's store as it does on the mesh: %+v / %+v", m.Described, m.Base, m.Change)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **Issue 263**: a change makes the network manager require the object store's provision; on a machine
|
|
// whose name is six characters its identity is 26 against a bound of 20. Refused in the pull request,
|
|
// naming the module, and not on the anchor after it merged.
|
|
func TestIssue263AnIdentityARealMachineNameOverflowsFailsThePullRequest(t *testing.T) {
|
|
f, manifests := catalogueMesh(t)
|
|
tree := aTree(t, withEdit(manifests, "networkmanager",
|
|
`{"module":"networkmanager","version":"1","requires":["wildcard-resolution","s3-bucket"]}`))
|
|
v := gateJudged(t, f, tree)
|
|
if v.Verdict != "fail" {
|
|
t.Fatalf("the overflow passed the gate:\n%s", v.Report())
|
|
}
|
|
report := v.Report()
|
|
if !strings.Contains(report, "networkmanager") || !strings.Contains(report, "s3-bucket") {
|
|
t.Errorf("the refusal does not name the module and the provision:\n%s", report)
|
|
}
|
|
}
|
|
|
|
// **Issue 236**: a login manager's service that omits its state passed the catalogue check and was
|
|
// refused whole by the node-engine on the first machine. A change to a module a machine runs, and a
|
|
// module nobody runs yet, are both refused before merge, naming the machine and the module.
|
|
func TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest(t *testing.T) {
|
|
f, manifests := catalogueMesh(t)
|
|
broken := `{"module":"lemurs","version":"1","capabilities":["systemd"],
|
|
"resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`
|
|
v := gateJudged(t, f, aTree(t, withEdit(manifests, "lemurs", broken)))
|
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "lemurs") {
|
|
t.Fatalf("a service the node-engine refuses passed the gate:\n%s", v.Report())
|
|
}
|
|
laptop := snapshot.Pseudonym("machine", "laptop")
|
|
if !strings.Contains(v.Report(), laptop) {
|
|
t.Errorf("the refusal does not name the machine it would be refused on:\n%s", v.Report())
|
|
}
|
|
|
|
// And as a new module, which no machine runs: tried on one that could.
|
|
newcomer := strings.ReplaceAll(broken, `"lemurs"`, `"greeter"`)
|
|
newcomer = strings.ReplaceAll(newcomer, "lemurs.service", "greeter.service")
|
|
v = gateJudged(t, f, aTree(t, withEdit(manifests, "greeter", newcomer)))
|
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "greeter, assigned to") {
|
|
t.Fatalf("a new module the node-engine would refuse passed the gate:\n%s", v.Report())
|
|
}
|
|
}
|
|
|
|
// **Version skew**: a manifest the controller judging it cannot read — the one the mesh runs, for a
|
|
// catalogue change — fails here, not at registration after the merge.
|
|
func TestAManifestTheRunningControllerCannotReadFailsThePullRequest(t *testing.T) {
|
|
f, manifests := catalogueMesh(t)
|
|
v := gateJudged(t, f, aTree(t, withEdit(manifests, "album",
|
|
`{"module":"album","version":"1","requires":["s3-bucket"],"a-field-of-a-newer-controller":true}`)))
|
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "refuses it") {
|
|
t.Fatalf("a manifest this controller cannot read passed:\n%s", v.Report())
|
|
}
|
|
}
|
|
|
|
// A module a machine runs, removed from its source, fails until it is unassigned (ADR 0236).
|
|
func TestAModuleAMachineRunsRemovedFromItsSourceFails(t *testing.T) {
|
|
f, manifests := catalogueMesh(t)
|
|
v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", "")))
|
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "album is removed") {
|
|
t.Fatalf("removing a module a machine runs passed:\n%s", v.Report())
|
|
}
|
|
}
|
|
|
|
// **Issues 278 and 280**: a file in no held module's directory read as shared code, and a merge rebuilt the
|
|
// catalogue. A file is a module's only by being inside it — no build reads one outside — so it rebuilds
|
|
// nothing, and the gate says why; a merge that does rebuild widely is warned of.
|
|
func TestIssue278AWideRebuildIsSaidBeforeTheMerge(t *testing.T) {
|
|
f, manifests := catalogueMesh(t)
|
|
was := wideRebuild
|
|
wideRebuild = 2
|
|
t.Cleanup(func() { wideRebuild = was })
|
|
for _, file := range []string{"modules/showcase/index.ts", "merge-check.sh", "README.md"} {
|
|
v := gateJudged(t, f, aTree(t, manifests), file)
|
|
if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 1 || v.Verdict != "pass" {
|
|
t.Fatalf("%s, read by no build, reads %+v, %s", file, v.Width, v.Verdict)
|
|
}
|
|
if !strings.Contains(v.Report(), "read by no module's build") {
|
|
t.Errorf("why %s rebuilds nothing is not said:\n%s", file, v.Report())
|
|
}
|
|
}
|
|
v := gateJudged(t, f, aTree(t, manifests), "modules/album/x.ts", "modules/objects/x.go", "modules/resolver/x.go")
|
|
if v.Width == nil || len(v.Width.Modules) < 3 || v.Verdict != "warning" {
|
|
t.Fatalf("a rebuild wider than the bound is not warned of: %+v, %s", v.Width, v.Verdict)
|
|
}
|
|
// With the reference module's definition in the tree, its directory is a module, held or not.
|
|
withShowcase := withEdit(manifests, "showcase", `{"module":"showcase","version":"1"}`)
|
|
v = gateJudged(t, f, aTree(t, withShowcase), "modules/showcase/index.ts")
|
|
if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 0 {
|
|
t.Fatalf("a file of a module nobody holds reads %+v", v.Width)
|
|
}
|
|
v = gateJudged(t, f, aTree(t, manifests), "modules/album/module.json")
|
|
if v.Width == nil || strings.Join(v.Width.Modules, ",") != "album" || v.Verdict != "pass" {
|
|
t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict)
|
|
}
|
|
}
|
|
|
|
// **A delivery group is judged as one future state** (novox/hq ADR 0239): a catalogue change that needs a
|
|
// provision only another repository's change adds fails alone and passes composed with it; and a group
|
|
// whose other head breaks what the first needs fails, naming it.
|
|
func TestADeliveryGroupsHeadsAreComposedTogether(t *testing.T) {
|
|
f, manifests := catalogueMeshWithAnApp(t)
|
|
needsTheApp := withEdit(manifests, "album", `{"module":"album","version":"1","requires":["s3-bucket","app-api"]}`)
|
|
catTree := aTree(t, needsTheApp)
|
|
alone := gateJudged(t, f, catTree, "modules/album/module.json")
|
|
if alone.Verdict != "fail" {
|
|
t.Fatalf("a requirement nothing provides passed alone:\n%s", alone.Report())
|
|
}
|
|
app := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1",
|
|
"provides":[{"name":"app-api","scope":"mesh","identity":false}]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in := mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), repository: "novox/mesh-catalog",
|
|
tree: catTree, changed: []string{"modules/album/module.json"},
|
|
group: []groupChange{{Repository: "novox/app", Tree: app, Changed: []string{"module.json"}}}}
|
|
together, err := judgeChange(t.Context(), in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if together.Verdict == "fail" {
|
|
t.Fatalf("the group composed together fails:\n%s", together.Report())
|
|
}
|
|
if together.Modules["app"] != "changed" || together.Modules["album"] != "changed" {
|
|
t.Fatalf("the group's heads were not both laid over the mesh: %v", together.Modules)
|
|
}
|
|
}
|
|
|
|
// catalogueMeshWithAnApp is catalogueMesh with an application built from a repository of its own, at its
|
|
// root, on the anchor.
|
|
func catalogueMeshWithAnApp(t *testing.T) (snapshot.Facts, map[string]string) {
|
|
t.Helper()
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
manifests := map[string]string{
|
|
"objects": `{"module":"objects","version":"1",
|
|
"provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}],
|
|
"receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`,
|
|
"album": `{"module":"album","version":"1","requires":["s3-bucket"]}`,
|
|
}
|
|
for name, raw := range manifests {
|
|
m, err := catalogue.ParseManifest([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
|
Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
|
|
inventory.Source{Repository: "novox/app", BuiltFrom: "c0ffee"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "app"}, {"laptop", "album"}} {
|
|
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
|
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
|
}
|
|
}
|
|
f, err := gatherFacts(ctx, open, "2.11.17")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f, manifests
|
|
}
|
|
|
|
// busMesh is aMesh with a module on the bus on the laptop, its account issued as `module issue` issues
|
|
// one: minted, and its credential held as the module's own secret named broker.
|
|
func busMesh(t *testing.T) snapshot.Facts {
|
|
t.Helper()
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
m, err := catalogue.ParseManifest([]byte(`{"module":"speaker","version":"1",
|
|
"own-secrets":{"broker":"/var/lib/speaker/broker"}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
|
Path: "modules/speaker", BuiltFrom: "c0ffee"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := assign(ctx, open, "laptop", "speaker"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "speaker"}.Username()
|
|
password, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusModule,
|
|
Node: "laptop", Module: "speaker"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "speaker", "broker",
|
|
`{"user":"`+user+`","password":"`+password+`"}`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := gatherFacts(ctx, open, "2.11.17")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, mc := range f.Machines {
|
|
if !mc.Declaration.Composes {
|
|
t.Fatalf("the mesh itself does not compose: %+v", mc.Declaration)
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// **Issue 282**: every machine running a module on the bus failed to compose in the gate's store, with
|
|
// the change and without — the store held the module's credential and no account for it, which
|
|
// composition refuses (issue 203) — and the gate passed every change, "0 of 4 compose". The account the
|
|
// mesh issued is raised with its credential, so the machine composes in the gate as on the mesh.
|
|
func TestIssue282AModuleOnTheBusComposesInTheGate(t *testing.T) {
|
|
f := busMesh(t)
|
|
v := gateJudged(t, f, "")
|
|
if v.Verdict != "pass" {
|
|
t.Fatalf("the mesh as it is does not pass:\n%s", v.Report())
|
|
}
|
|
for _, m := range v.Machines {
|
|
if !m.Base.Composes {
|
|
t.Errorf("%s composes on the mesh and not in the gate: %v", m.Described, m.Base.Problems)
|
|
}
|
|
}
|
|
if !strings.Contains(v.Summary, "2 of 2 compose") {
|
|
t.Errorf("the summary does not say every machine composes: %s", v.Summary)
|
|
}
|
|
}
|
|
|
|
// **Issue 282**: a machine the mesh composes that the gate cannot raise as it is leaves the change judged
|
|
// against a machine that is not the mesh's — broken against broken, which passes whatever the change does.
|
|
// That is an error, never a pass.
|
|
func TestIssue282AMachineTheGateCannotRaiseIsAnErrorNeverAPass(t *testing.T) {
|
|
f := busMesh(t)
|
|
for i := range f.Machines {
|
|
// A fact the snapshot does not carry: the module's credential, gone from the laptop's.
|
|
var kept []snapshot.Accepted
|
|
for _, a := range f.Machines[i].Accepted {
|
|
if a.Name != "broker" {
|
|
kept = append(kept, a)
|
|
}
|
|
}
|
|
f.Machines[i].Accepted = kept
|
|
}
|
|
v := gateJudged(t, f, "")
|
|
if v.Verdict != "error" {
|
|
t.Fatalf("a gate whose mesh does not compose said %s:\n%s", v.Verdict, v.Report())
|
|
}
|
|
if len(v.Errors) != 1 || !strings.Contains(v.Errors[0], "speaker") {
|
|
t.Errorf("the error does not name what could not be raised: %v", v.Errors)
|
|
}
|
|
}
|
|
|
|
// A path the snapshot withheld is given a path of its own where an access or a place needs one: a bare
|
|
// "withheld" is no absolute path, and a machine whose setting composes on the mesh would not in the gate.
|
|
func TestAWithheldPathIsStoodInForByAPath(t *testing.T) {
|
|
got := standInPaths(map[string]any{
|
|
"accesses": map[string]any{"races": "withheld", "films": "/media/films", "shows": "/withheld"},
|
|
"places": map[string]any{"config": map[string]any{"path": "withheld", "owner": "1000:1000"}},
|
|
"puid": 1000,
|
|
})
|
|
accesses := got["accesses"].(map[string]any)
|
|
if accesses["races"] == accesses["shows"] || !strings.HasPrefix(accesses["races"].(string), "/") ||
|
|
!strings.HasPrefix(accesses["shows"].(string), "/") || accesses["films"] != "/media/films" {
|
|
t.Errorf("accesses: %v", accesses)
|
|
}
|
|
place := got["places"].(map[string]any)["config"].(map[string]any)
|
|
if !strings.HasPrefix(place["path"].(string), "/") || place["owner"] != "1000:1000" || got["puid"] != 1000 {
|
|
t.Errorf("places: %v", got)
|
|
}
|
|
}
|
|
|
|
// **Issue 283**: a check run by hand clones beside a change what the seat clones — one rule, read by the
|
|
// controller's ask and by the facts — and finds the repository it checks from its origin.
|
|
func TestACheckByHandClonesWhatTheSeatClones(t *testing.T) {
|
|
for dir, refs := range map[string]map[string]string{
|
|
"mesh-catalog": {"mesh-catalog": "main"},
|
|
"mesh-host": {"mesh-host": "c0ffee"},
|
|
"mesh-controller": {"mesh-controller": "c0ffee", "mesh-controller-main": "main", "mesh-lab": "main"},
|
|
} {
|
|
got := besideRefs(dir, "c0ffee")
|
|
for d, ref := range refs {
|
|
if got[d] != ref {
|
|
t.Errorf("beside %s, %s is cloned at %q, not %q", dir, d, got[d], ref)
|
|
}
|
|
}
|
|
}
|
|
for owner, want := range map[string][3]string{
|
|
"ssh://git@git.example:222/novox/mesh-host.git": {"novox", "mesh-host", "ssh://git@git.example:222/novox"},
|
|
"git@git.example:novox/mesh-tools.git": {"novox", "mesh-tools", "git@git.example:novox"},
|
|
"http://git.example/novox/hq": {"novox", "hq", "http://git.example/novox"},
|
|
} {
|
|
o, r, p := ownerRepoOf(owner)
|
|
if [3]string{o, r, p} != want {
|
|
t.Errorf("%s reads as %s %s %s", owner, o, r, p)
|
|
}
|
|
}
|
|
}
|