mesh/delivery held for a person: merged, and the controller opened no walk for it within 10m0s — nothing it holds follows that branch, or the merge was…
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
(cherry picked from commit 8bf7026d97)
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 285**: 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 TestIssue285AModuleOnTheBusComposesInTheGate(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 285**: 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 TestIssue285AMachineTheGateCannotRaiseIsAnErrorNeverAPass(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 286**: 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)
|
|
}
|
|
}
|
|
}
|