diff --git a/go/catalogue/amqp_is_not_a_provision_test.go b/go/catalogue/amqp_is_not_a_provision_test.go new file mode 100644 index 0000000..932c81d --- /dev/null +++ b/go/catalogue/amqp_is_not_a_provision_test.go @@ -0,0 +1,50 @@ +package catalogue + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants +// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming +// the old wire protocol asks for the one server being retired, so both directions are refused at the +// parser — this is judged from the manifest alone, no store needed. + +func TestAManifestProvidingAmqpIsRefused(t *testing.T) { + raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) { + t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err) + } +} + +func TestAManifestRequiringAmqpIsRefused(t *testing.T) { + raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) { + t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err) + } +} + +// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under +// design 28 task 5.4, not converted. +func TestNoCatalogueManifestNamesAmqp(t *testing.T) { + modules, err := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) + if err != nil || len(modules) == 0 { + t.Fatalf("no manifests in the catalogue, so this proved nothing: %v", err) + } + for _, path := range modules { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), `"amqp"`) { + t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)", + filepath.Base(filepath.Dir(path))) + } + } +} diff --git a/go/catalogue/artifact_store_seat_test.go b/go/catalogue/artifact_store_seat_test.go new file mode 100644 index 0000000..182dcac --- /dev/null +++ b/go/catalogue/artifact_store_seat_test.go @@ -0,0 +1,38 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The artifact store's seat is one per mesh, read from the catalogue beside this checkout. +// +// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat +// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a +// node-scoped requirement with one candidate installs that candidate on the node, so anything that +// required the store's presence beside it would have raised a fresh, empty store on the wrong +// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every +// consumer elsewhere refusing to choose. The claim says it first, where the second store is +// assigned. +func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { + store := catalogueManifest(t, "distribution") + + // The first store resolves as it always has. + if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), World{}); err != nil { + t.Fatalf("the store alone does not resolve: %v", err) + } + + // A second one, on any other machine, is refused — and the refusal names the seat. + elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh, + Node: "anchor", Module: "distribution"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere) + if err == nil { + t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + + "second time and every consumer elsewhere would refuse to choose") + } + if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} diff --git a/go/catalogue/broker_seat_test.go b/go/catalogue/broker_seat_test.go new file mode 100644 index 0000000..c04389a --- /dev/null +++ b/go/catalogue/broker_seat_test.go @@ -0,0 +1,87 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The mesh's bus is one per mesh, read from the catalogue beside this checkout. +// +// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116): +// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running, +// and the property that matters is that a second one anywhere is refused *when it is assigned*, +// not discovered later as two servers holding different halves of the mesh's traffic. A second +// bus is not a degraded mesh; it is two meshes that both believe they are the one. +func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) { + nats := catalogueManifest(t, "nats") + + if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), World{}); err != nil { + t.Fatalf("the bus alone does not resolve: %v", err) + } + + elsewhere := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, + Node: "anchor", Module: "nats"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere) + if err == nil { + t.Fatal("a second bus was accepted on another machine") + } + if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} + +// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different +// implementation of the bus claims the same seat, and the mesh refuses it for the same reason — +// which is the property that lets the bus be replaced at all. +func TestTheSeatRefusesADifferentBusToo(t *testing.T) { + nats := catalogueManifest(t, "nats") + held := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, + Node: "anchor", Module: "some-other-broker"}}} + other := workstation() + other.Name = "laptop" + if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil { + t.Fatal("the seat admitted a second holder because the module's name differed") + } +} + +// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no +// longer a fixture here. That two eligible holders stand beside each other with one on record is +// pinned in holdings_test.go against manifests this package owns. + +// **A seat and the interface it delivers are different names, and renaming one must not rename +// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*` +// prefix, and a blanket search-and-replace also renamed `npm-package-registry` and `git` where +// they are *provisions* — which a consumer requires and a provider offers. The tests failed with +// "the package registry is served on ", which does not say "you renamed an interface". +func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) { + for _, pair := range []struct{ seat, delivers string }{ + {"git", "git"}, + {"npm-package-registry", "npm-package-registry"}, + {"mesh-artifact-store", "artifact-store"}, + {"mesh-store", "postgres-database"}, + {"mesh-broker", "mesh-bus"}, + } { + s, known := SeatNamed(pair.seat) + if !known { + t.Fatalf("%q is not a seat", pair.seat) + } + if s.Delivers != pair.delivers { + t.Errorf("the %s seat delivers %q, expected %q — renaming the seat moved the "+ + "interface with it, and every consumer requiring it would stop resolving", + pair.seat, s.Delivers, pair.delivers) + } + // **Three of these deliberately share a name with what they deliver**, and that is not an + // incomplete rename. Renaming a seat that delivers a provision cascades to every consumer + // requiring it, with a mesh-wide window where a holder stops resolving mid-flight — so the + // trunk deferred exactly those three (novox/hq ADR 0121) while renaming the node-scoped ones. + // What this test is for is the other direction: that renaming a seat never moves the + // interface, which once produced "the package registry is served on ". + } +} + +// A manifest written against an old seat name is told what it became rather than refused as +// unknown. **That map is the controller's store now, not this package** (novox/hq ADR 0122): a +// rename is a row, so the courtesy survives a rename nobody recompiled for. Checked where the +// table is read, not here, where there is no longer a hardcoded list to check against. diff --git a/go/catalogue/bus_snapshot_test.go b/go/catalogue/bus_snapshot_test.go new file mode 100644 index 0000000..e990435 --- /dev/null +++ b/go/catalogue/bus_snapshot_test.go @@ -0,0 +1,80 @@ +package catalogue + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and +// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted +// nothing, so it is refused at the parser rather than silently dropped. +func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { + raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "emits":["something.happened"]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") { + t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) + } + + // Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is + // granted the module's events, so a bus module that ships one may declare them. + bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "emits":["bus.slow-consumer"],"tools":["bus_streams"], + "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`) + if _, err := ParseManifest(bundled); err != nil { + t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err) + } + + quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "tools":["bus_streams"]}`) + m, err := ParseManifest(quiet) + if err != nil { + t.Fatalf("a bus module with an account and tools was refused: %v", err) + } + if !m.ClaimsSeat(BrokerSeat) { + t.Fatal("it does not read as holding the broker seat") + } +} + +// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a +// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot +// program in the bus's own container. +func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "nats", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + if _, account := m.OwnSecrets["broker"]; !account { + t.Fatal("the bus declares no account, so its snapshot could not reach it") + } + var found bool + if m.Data == nil { + t.Fatal("the bus declares no data") + } + for _, it := range m.Data.Own { + if it.ID != "jetstream" { + continue + } + found = true + if !it.Backup.IsDump() || it.Backup.Into != "snapshots" { + t.Fatalf("the bus's streams are protected by %+v, not a snapshot into its snapshots", it.Backup) + } + if !strings.Contains(it.Backup.Dump, "mesh-nats-snapshot snapshot") { + t.Fatalf("the dump does not run the snapshot program: %s", it.Backup.Dump) + } + } + if !found { + t.Fatal("the bus declares no item for its streams") + } +} diff --git a/go/catalogue/catalogue_check_test.go b/go/catalogue/catalogue_check_test.go new file mode 100644 index 0000000..f853c01 --- /dev/null +++ b/go/catalogue/catalogue_check_test.go @@ -0,0 +1,138 @@ +package catalogue + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. +// +// Not a fixture: the point is whether the manifests as written are accepted by the control plane that +// will read them, and a copy of one manifest proves nothing about the other seventy-one. +// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put +// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only +// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read +// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. +func catalogueRoot(t *testing.T) string { + t.Helper() + return beside.Dir(t, "mesh-catalog") +} + +func TestEveryCatalogueManifestParses(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + named, routed := 0, 0 + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err) + continue + } + for _, l := range m.Listens { + if l.Name != "" { + named++ + } + } + for port := range RoutedPorts(m) { + _ = port + routed++ + } + } + t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed) + if named == 0 { + t.Fatal("no endpoint in the catalogue is named, so this proved nothing") + } +} + +// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no +// definition names a domain or a public address the mesh acts on, and every value that must for now +// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks. +func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + var named []string + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + t.Errorf("%s: %v", p, err) + continue + } + named = append(named, InstallationProblems(m)...) + } + if len(named) > 0 { + t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n ")) + } +} + +// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR +// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a +// backup line by hand, every directory a container writes is declared, and every irreplaceable item is +// backed up — so a store added is a store backed up, without a list of stores to keep in step. +func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + shelf := Shelf{} + granting := 0 + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + continue // TestEveryCatalogueManifestParses says why + } + if len(m.Grants) > 0 { + granting++ + } + shelf[m.Module] = m + } + for _, problem := range DataProblems(shelf) { + t.Error(problem) + } + if granting == 0 { + t.Fatal("no module in the catalogue grants a provision, so this proved nothing") + } +} + +// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every +// module's identity, on the longest machine name, fits the bound of every provision it wants. An +// overflow fails here, in the pull request that introduces it, rather than on the provider's machine +// the first time a real machine's name meets the module's (issue 263). +func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + shelf := Shelf{} + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + shelf[m.Module] = m + } + for _, p := range IdentityProblems(shelf, DefaultLongestMachine) { + t.Error(p) + } + // The night it was found: the resolver provision bounds nothing, and the object store still 20. + if dns, ok := shelf["dnsmasq"]; ok { + if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() { + t.Errorf("the resolver provision bounds its consumers' identities: %+v", b) + } + } + if store, ok := shelf["minio"]; ok { + if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 { + t.Errorf("the object store's access key is not bounded at 20: %+v", b) + } + } +} diff --git a/go/catalogue/catrust_manifest_test.go b/go/catalogue/catrust_manifest_test.go new file mode 100644 index 0000000..dfad82c --- /dev/null +++ b/go/catalogue/catrust_manifest_test.go @@ -0,0 +1,69 @@ +package catalogue + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR +// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless +// the mesh fills in where the authority is — which is the one thing about it the manifest cannot +// state, because the authority's address is a fact about the mesh and not about the module. +// +// So what is checked here is the rendering, not the parsing: the script the machine will run +// names the authority it was bound to, and the unit runs that script both ways. The verification +// itself — a plain client trusting an internal name on a machine holding this, and failing on one +// that does not — is the lab's, and cannot be had here. +func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { + m, err := ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) + if err != nil { + t.Fatalf("the trust module does not parse:\n%v", err) + } + + r := Resolution{ + Node: "workstation", + Modules: []Manifest{m}, + Needs: []Needed{{ + Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatalf("the trust module could not be composed for a machine: %v", err) + } + + script := fileNamed(out, "ca-trust.anchor") + if script == nil { + t.Fatalf("nothing writes the script the unit runs: %v", out) + } + body, _ := script["content"].(string) + if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { + t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) + } + if script["mode"] != "0755" { + t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) + } + + unit := fileNamed(out, "ca-trust.unit") + if unit == nil { + t.Fatalf("no unit: %v", out) + } + text, _ := unit["content"].(string) + // Both halves. A unit that only installs the anchor leaves a machine trusting an authority + // nobody assigned it to any more, which is the half issue 129 asked for by name. + for _, want := range []string{ + "ExecStart=" + script["path"].(string) + " install", + "ExecStop=" + script["path"].(string) + " remove", + "RemainAfterExit=yes", + } { + if !strings.Contains(text, want) { + t.Errorf("the unit does not say %q:\n%s", want, text) + } + } +} diff --git a/go/catalogue/collections_bar_test.go b/go/catalogue/collections_bar_test.go new file mode 100644 index 0000000..54de9d6 --- /dev/null +++ b/go/catalogue/collections_bar_test.go @@ -0,0 +1,135 @@ +package catalogue + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// A bar's blocks are a collection of text (design 57 L3, issue 781): the holder of node-bar names the kind as +// `${collected:contribution:node-bar:block:bar=,place=

}`, which a handed collector has its client fill from the +// items and an unhanded one has the controller fill as it fills the old placeholder. + +const ( + collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" + + "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}" + collectedBars = `{"module":"bars","version":"1","moved":true, + "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + itemsOfPower = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` +) + +func collectedBarsOn(t *testing.T, caps map[string]bool) Resolution { + t.Helper() + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + return r +} + +func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) { + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + if _, err := collectedBarsOn(t, nil).Compose(handingTo("store:5000")); err != nil { + t.Fatalf("the handed collector is refused: %v", err) + } + if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x", + "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 { + t.Fatalf("the check refuses it: %v", got) + } + // A data kind whose shape does not say its items are text stays refused. + _, plain, _ := receivable(BarSeat, BarKindBlock) + plain.Shape = &Shape{} + if collectableAsText(plain) { + t.Fatal("a shape not marked as text is collectable") + } + if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil { + t.Fatal(err) + } +} + +func TestTheBarsCollectionIsFilledTheOldWayWhereTheCollectorIsNotHanded(t *testing.T) { + for _, caps := range []map[string]bool{nil, {"battery": true}} { + got := composedFile(t, collectedBarsOn(t, caps), "bars.conf") + want := "top\nbottom\n# power\nD workstation\n" + if caps["battery"] { + want = "top\nbottom\n# power\nB\n# power\nD workstation\n" + } + if got != want { + t.Errorf("caps %v: the file is %q, want %q", caps, got, want) + } + } +} + +func TestTheBarsCollectionIsNamedInTheHandedPlan(t *testing.T) { + c, _ := composeWith(t, collectedBarsOn(t, map[string]bool{"battery": true}), handingTo("store:5000")) + if !reflect.DeepEqual(c.Plans["bars"].Collections, []PlanCollection{{Name: "contribution:node-bar:block", Comment: "#"}}) { + t.Fatalf("the plan collects %+v", c.Plans["bars"].Collections) + } + if n := len(c.Plans["power"].Items); n != 2 { + t.Fatalf("the contributor's plan names %d items", n) + } +} + +// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped +// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as +// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the +// collector is not handed the controller fills the files; where it is, it names the items for the client, which +// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files). +func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "bars", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"}, + {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}` + power := `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true}, + "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} { + bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")})) + blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")})) + r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + // Not handed: the controller fills them. + for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} { + if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") { + t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml")) + } + } + // Handed: the plan names the items the machine's capabilities allow, for the client to fill. + c, _ := composeWith(t, r, handingTo("store:5000")) + var names []string + for _, it := range c.Plans["power"].Items { + names = append(names, it.Name) + } + want := []string{} + if caps["battery"] { + want = append(want, "battery") + } + if caps["power-meter"] { + want = append(want, "power-draw") + } + if !reflect.DeepEqual(append([]string{}, names...), want) { + t.Errorf("%s: the plan names %v, want %v", name, names, want) + } + } +} diff --git a/go/catalogue/collections_l3_test.go b/go/catalogue/collections_l3_test.go new file mode 100644 index 0000000..4c687a4 --- /dev/null +++ b/go/catalogue/collections_l3_test.go @@ -0,0 +1,158 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// An item may carry attributes and a capability, a module may give several items to one collection, and a +// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose +// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a +// selected moved item into an old placeholder exactly as the contribution it replaces. + +const ( + barCollector = `{"module":"bars","version":"1","moved":true, + "collections":[{"name":"blocks","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}" + barContribute = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"}, + {"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` +) + +func blocksOn(t *testing.T, caps map[string]bool) Composed { + t.Helper() + bars := held(t, barCollector, builtWith("bars", map[string]string{"bars": barsText})) + power := held(t, barContribute, builtWith("power", map[string]string{"battery": "B\n", "clock": "C\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + c, _ := composeWith(t, r, handingTo("store:5000")) + return c +} + +func TestAnItemWhoseCapabilityTheMachineLacksIsLeftOutOfItsPlan(t *testing.T) { + with := blocksOn(t, map[string]bool{"battery": true}).Plans["power"] + want := []PlanItem{ + {Collection: "blocks", Name: "battery", Template: "battery", + Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, + {Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}}, + } + if !reflect.DeepEqual(with.Items, want) { + t.Fatalf("with the capability the plan carries %+v", with.Items) + } + without := blocksOn(t, map[string]bool{"seat": true}).Plans["power"] + if !reflect.DeepEqual(without.Items, want[1:]) { + t.Fatalf("without it the plan carries %+v", without.Items) + } +} + +func TestACollectorSelectingByAttributesCollectsTheCollectionOnce(t *testing.T) { + bars := blocksOn(t, nil).Plans["bars"] + if !reflect.DeepEqual(bars.Collections, []PlanCollection{{Name: "blocks", Comment: "#"}}) { + t.Fatalf("the plan collects %+v", bars.Collections) + } +} + +func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) { + for placeholder, want := range map[string]string{ + "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar + "${collected:blocks:bar=top,bar=bottom}": "names bar twice", + "${collected:blocks:bar=}": "is not =", + "${collected:other:bar=top}": `does not collect "other"`, + } { + raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) + raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) + raw = strings.Replace(raw, `"moved":true,`, "", 1) + m, err := ParseManifest([]byte(raw)) + if err == nil { + _, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{}) + } + if want == "" { + continue + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", placeholder, err, want) + } + } +} + +func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { + item := func(extra string) string { + return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"items":[` + extra + `]}` + } + for items, want := range map[string]string{ + `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name", + `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice", + `{"collection":"c","name":"Big","template":"files/a"}`: "is not a name", + `{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99", + `{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value", + `{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports", + `{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left", + `{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`, + `{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour", + } { + _, err := ParseManifest([]byte(item(items))) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", items, err, want) + } + } + ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`) + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Fatal(err) + } +} + +// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item +// name) and with the same lines as the contributions they replace. +func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) { + piece := func(order, command string) string { + return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n" + } + asOld := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`), + block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`), + block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`), + }} + asItems := Manifest{Module: "power", Moved: true, Items: []Item{ + {Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"), + Attributes: map[string]string{"bar": "bottom", "place": "status"}}, + {Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"), + Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, + {Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"), + Attributes: map[string]string{"bar": "top", "place": "resources"}}, + {Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery", + Attributes: map[string]string{"bar": "bottom", "place": "status"}}, + }} + other := Manifest{Module: "another", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}} + for _, caps := range []map[string]bool{nil, {"battery": true}} { + was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, + Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom") + is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, + Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom") + // The old form puts the contributions of a module under one line naming it and the item form puts one under + // each item: the same pieces in the same order, otherwise the same bytes. + want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") + + "# power\n" + piece("", "one") + if caps["battery"] { + want += "# power\nx\n" + } + want += "own-last\n" + if is != want { + t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want) + } + if !strings.Contains(was, piece("", "two")) { + t.Errorf("the contributions made\n%s", was) + } + } + if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) { + t.Errorf("the top bar renders\n%s", got) + } +} diff --git a/go/catalogue/engine_guard_test.go b/go/catalogue/engine_guard_test.go new file mode 100644 index 0000000..85b7597 --- /dev/null +++ b/go/catalogue/engine_guard_test.go @@ -0,0 +1,71 @@ +package catalogue + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "strconv" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue +// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone +// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists +// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor +// passes what it refuses by path alone. +func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) { + file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go") + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) + if err != nil { + t.Fatalf("the node-engine's guard does not parse: %v", err) + } + lists := map[string][]string{} + consts := map[string]string{} + ast.Inspect(parsed, func(n ast.Node) bool { + spec, ok := n.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range spec.Names { + if i >= len(spec.Values) { + continue + } + switch v := spec.Values[i].(type) { + case *ast.CompositeLit: + for _, elt := range v.Elts { + if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING { + s, _ := strconv.Unquote(lit.Value) + lists[name.Name] = append(lists[name.Name], s) + } + } + case *ast.BasicLit: + if v.Kind == token.STRING { + consts[name.Name], _ = strconv.Unquote(v.Value) + } + } + } + return true + }) + for name, ours := range map[string][]string{ + "protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees, + } { + theirs := lists[name] + if len(theirs) == 0 { + t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name) + continue + } + a, b := slices.Clone(ours), slices.Clone(theirs) + slices.Sort(a) + slices.Sort(b) + if !slices.Equal(a, b) { + t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b) + } + } + if consts["engineModule"] != engineModule { + t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule) + } +} diff --git a/go/catalogue/environment_items_test.go b/go/catalogue/environment_items_test.go new file mode 100644 index 0000000..cd4c93b --- /dev/null +++ b/go/catalogue/environment_items_test.go @@ -0,0 +1,165 @@ +package catalogue + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The session environment is a collection of typed items (design 57 L3, issue 795): a variable is an item with +// kind=variable and name=, a PATH entry one with kind=path and at=start|end, each a template whose text is the +// value or the entry. Where node-env is not handed the controller renders the items as the old form was rendered; +// where it is, it names the collection for the client, which fills the same files (mesh-catalog's provisioner client +// holds that half against the same golden files). testdata/environment holds the environment of the catalogue's five +// contributors as the old form composed it, with all five and with zsh alone. + +type envDecl struct{ module, item, name, value string } + +func environmentOfTheCatalogue() []envDecl { + return []envDecl{ + {"adwaita", "gtk-theme", "GTK_THEME", "Adwaita:dark"}, + {"adwaita", "gtk2-rc-files", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"}, + {"adwaita", "qt-qpa-platformtheme", "QT_QPA_PLATFORMTHEME", "qt6ct"}, + {"adwaita", "qt-style-override", "QT_STYLE_OVERRIDE", "Fusion"}, + {"adwaita", "qt-select", "QT_SELECT", "6"}, + {"adwaita", "xcursor-theme", "XCURSOR_THEME", "Adwaita"}, + {"adwaita", "xcursor-size", "XCURSOR_SIZE", "24"}, + {"clipmenu", "cm-selections", "CM_SELECTIONS", "clipboard"}, + {"clipmenu", "cm-max-clips", "CM_MAX_CLIPS", "500"}, + {"clipmenu", "cm-histlength", "CM_HISTLENGTH", "15"}, + {"i3", "xdg-current-desktop", "XDG_CURRENT_DESKTOP", "i3"}, + {"i3", "xdg-session-desktop", "XDG_SESSION_DESKTOP", "i3"}, + {"xterm", "terminal", "TERMINAL", "xterm"}, + {"zsh", "editor", "EDITOR", "vim"}, + {"zsh", "visual", "VISUAL", "vim"}, + {"zsh", "xdg-config-home", "XDG_CONFIG_HOME", "${machine:account-home}/.config"}, + {"zsh", "path-local-bin", "path:10", "${machine:account-home}/.local/bin"}, + {"zsh", "path-scripts", "path:20", "${machine:account-home}/scripts"}, + {"zsh", "path-scripts-bin", "path:30", "${machine:account-home}/scripts/bin"}, + } +} + +const environmentCollector = `{"module":"node-env","version":"1","moved":true, + "claims":[{"name":"node-environment","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"posix","type":"file","path":"/home/op/.config/mesh/environment.sh","template":"files/posix"}, + {"id":"systemd","type":"file","path":"/home/op/.config/environment.d/50-mesh.conf","template":"files/systemd"}]}` + +// nodeEnvWith is node-env, its file placing the environment as plan says, with the declared contributors' items. +func nodeEnvWith(t *testing.T, decls []envDecl, plan string) Resolution { + t.Helper() + collector := held(t, environmentCollector, builtWith("node-env", map[string]string{ + "posix": "# header\n" + plan, "systemd": "${environment:systemd}"})) + mods := []Manifest{collector} + order := []string{"node-env"} + byModule := map[string][]envDecl{} + var modules []string + for _, d := range decls { + if byModule[d.module] == nil { + modules = append(modules, d.module) + } + byModule[d.module] = append(byModule[d.module], d) + } + for _, name := range modules { + var items []string + texts := map[string]string{} + for _, d := range byModule[name] { + attrs := fmt.Sprintf(`{"kind":"variable","name":%q}`, d.name) + if order, isPath := strings.CutPrefix(d.name, "path:"); isPath { + attrs = fmt.Sprintf(`{"kind":"path","at":"start","order":%q}`, order) + } + items = append(items, fmt.Sprintf(`{"collection":"environment","name":%q,"attributes":%s,"template":"files/env-%s"}`, + d.item, attrs, d.item)) + texts["env-"+d.item] = d.value + "\n" + } + raw := fmt.Sprintf(`{"module":%q,"version":"1","moved":true,"items":[%s], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, name, strings.Join(items, ",")) + mods = append(mods, held(t, raw, builtWith(name, texts))) + order = append(order, name) + } + r, err := Resolve(shelf(mods...), order, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "environment", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + for name, only := range map[string]string{"all": "", "zsh": "zsh"} { + var decls []envDecl + for _, d := range environmentOfTheCatalogue() { + if only == "" || d.module == only { + decls = append(decls, d) + } + } + // Named as the collector names it, and as the old placeholder does. + for _, plan := range []string{"${collected:environment:posix}", "${environment:posix}"} { + r := nodeEnvWith(t, decls, plan) + // Not handed: the controller renders each moved contributor's items as the old form was rendered. + if got, want := composedFile(t, r, "node-env.posix"), "# header\n"+read(name+".posix"); got != want { + t.Errorf("%s, %s, not handed, the POSIX file is\n%s\nwant\n%s", name, plan, got, want) + } + if got, want := composedFile(t, r, "node-env.systemd"), read(name+".systemd"); got != want { + t.Errorf("%s, %s, not handed, the environment.d file is\n%s\nwant\n%s", name, plan, got, want) + } + // Handed: one collection, named for the client; each contributor's plan names its items. + c, _ := composeWith(t, r, handingTo("store:5000")) + if got := c.Plans["node-env"].Collections; !reflect.DeepEqual(got, []PlanCollection{{Name: "environment", Comment: "#"}}) { + t.Errorf("%s: the plan collects %+v", name, got) + } + items, got := len(decls), 0 + for m := range c.Plans { + got += len(c.Plans[m].Items) + } + if got != items { + t.Errorf("%s: the plans name %d items, want %d", name, got, items) + } + } + } +} + +func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) { + item := func(attrs map[string]string) []string { + return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs}) + } + for _, ok := range []map[string]string{ + {"kind": "variable", "name": "GTK_THEME"}, + {"kind": "path", "at": "end", "order": "10"}, + } { + if p := item(ok); len(p) != 0 { + t.Errorf("%v is refused: %v", ok, p) + } + } + for _, bad := range []map[string]string{ + nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"}, + {"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"}, + {"kind": "variable", "name": "A", "color": "red"}, + } { + if p := item(bad); len(p) == 0 { + t.Errorf("%v is taken", bad) + } + } + if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") || + attributeFits("environment", "kind", "Variable") { + t.Error("an upper-case name is the environment's name alone") + } + // A module that sets a variable twice, in two modules, is refused as the old form refused it. + a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n", + Attributes: map[string]string{"kind": "variable", "name": "X"}}}} + b := Manifest{Module: "b", Items: []Item{{Collection: "environment", Content: "2\n", + Attributes: map[string]string{"kind": "variable", "name": "X"}}}} + if err := variablesSetOnce([]Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") { + t.Errorf("two modules setting X: %v", err) + } +} diff --git a/go/catalogue/foundation_manifests_test.go b/go/catalogue/foundation_manifests_test.go new file mode 100644 index 0000000..88e64de --- /dev/null +++ b/go/catalogue/foundation_manifests_test.go @@ -0,0 +1,368 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it, +// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the +// real parser (novox/hq ADR 0100): +// the store and the broker say which of their ports the mesh guards on an adopted node, and the +// filter module loads its table through a unit of its own whose stop deletes only that table. +// moduleTree is where a module is read from: this repository's own modules/ when it lives whole here +// (novox/hq ADR 0420 §1, issue 1019: build-agent and route-proxy), with this repository as the tree root; +// otherwise the catalogue beside, with the catalogue as the root. +func moduleTree(t *testing.T, module string) (root, dir string) { + t.Helper() + own := filepath.Join("..", "..", "modules", module) + if _, err := os.Stat(filepath.Join(own, "module.json")); err == nil { + return filepath.Join("..", ".."), own + } + return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module) +} + +func catalogueManifest(t *testing.T, module string) Manifest { + t.Helper() + root, dir := moduleTree(t, module) + m, err := ReadTreeManifest(root, dir) + if err != nil { + t.Fatalf("%s does not parse:\n%v", module, err) + } + return m +} + +func TestTheStoreAndTheBrokerSayWhatTheMeshGuards(t *testing.T) { + if got := catalogueManifest(t, "postgres").Guards; !reflect.DeepEqual(got, []int{5432}) { + t.Errorf("postgres guards %v; the store's port must be refused from outside", got) + } + // The bus's monitoring port, not its client port: a node reaches the bus, nobody outside + // reads its state (novox/hq ADR 0131 — the broker that guarded 15672 has left the catalogue). + if got := catalogueManifest(t, "nats").Guards; !reflect.DeepEqual(got, []int{8222}) { + t.Errorf("nats guards %v; the monitoring port must be refused from outside", got) + } +} + +func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) { + m := catalogueManifest(t, "nftables") + var unit, stock, load map[string]any + for _, r := range m.Resources { + switch r["id"] { + case "unit": + unit = r + case "stock-unit-stop": + stock = r + case "load": + load = r + } + } + if load == nil || load["unit"] != "mesh-filter.service" { + t.Fatalf("the filter is not loaded by its own unit: %v", load) + } + content, _ := unit["content"].(string) + if unit == nil || unit["path"] != "/etc/systemd/system/mesh-filter.service" { + t.Fatalf("the filter's unit is not written: %v", unit) + } + if strings.Contains(content, "flush") { + t.Fatalf("stopping the filter flushes the whole ruleset — the runtime's and the found "+ + "firewall's with it:\n%s", content) + } + if !strings.Contains(content, "ExecStop=nft delete table inet mesh\n") || + !strings.Contains(content, "ExecStart=nft -f "+m.Filtering.Into+"\n") { + t.Fatalf("the unit does not load the computed rule set and delete only its own table:\n%s", + content) + } + // A node converged before the filter had its own unit still has the stock nftables.service + // enabled, whose stop flushes the whole ruleset: a drop-in makes it delete only the mesh's + // table, and the load is restarted on it so the host reloads units and the drop-in is read. + if stock == nil || stock["path"] != "/etc/systemd/system/nftables.service.d/mesh.conf" || + !strings.HasSuffix(fmt.Sprint(stock["content"]), + "[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n") { + t.Fatalf("the stock unit's stop is not replaced with deleting the mesh's table: %v", stock) + } + // A changed rule set is RELOADED — ExecReload replaces the table in one `nft -f`, so the node + // is never unfiltered — and only the units themselves restart it, which is the one change a + // reload cannot carry. + if !reflect.DeepEqual(load["reload-on"], []any{"filtering"}) { + t.Fatalf("the filter is restarted rather than reloaded when its rules change, leaving the "+ + "node unfiltered in between: %v", load) + } + if !reflect.DeepEqual(load["restart-on"], []any{"unit", "stock-unit-stop"}) { + t.Fatalf("the filter is not restarted when its unit or the stock unit's drop-in changes: %v", + load["restart-on"]) + } +} + +// The package registry's port is the node's, like every other foundation port (novox/hq +// 04-ISSUES/085, ADR 0100). The forge is reached through what it says it serves, and consumers — +// the builder among them — are told that, rather than carrying a number of their own. + +func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) { + forge := catalogueManifest(t, "gitea") + + // The catalogue's number is a default and the node's setting moves it. + given, err := GivenPorts(forge, []Layer{{From: "anchor", + Placement: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}}) + if err != nil { + t.Fatalf("the forge's port cannot be given on a node: %v", err) + } + if given[3000] != 3100 { + t.Fatalf("the forge was given %v", given) + } + + // And every consumer of the package registry is told where the machine actually put it, + // because that is read from what the forge serves rather than written in the consumer. + if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 { + t.Errorf("the package registry is served on %v, not the port this node gave it", got) + } + if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) { + t.Errorf("without a setting the forge serves %v, not the catalogue's port", got) + } + // And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason: + // a build composes the URL from what the forge serves, so a given port is a followed port. + if got := ServedOn(forge, "git", given)["port"]; got != 3100 { + t.Errorf("git is served on %v, not the port this node gave the forge", got) + } +} + +// **The builder requires the registry the npm seat delivers, and carries no binding of its own.** +// +// It used to carry a hand-written binding because nothing provided a package registry to resolve +// one from at genesis. The catalogue now requires it like any consumer, and ADR 0110 makes the +// seat's holder the answer when more than one module provides it — so a carried copy would be a +// second answer to the same question, free to drift from the first. Asserted gone, not merely +// unused. +// +// The builder is the build-agent on every machine since novox/hq ADR 0190; this read the retired +// `builder` and, finding no manifest, skipped unseen from then until novox/hq issue 432. +func TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers(t *testing.T) { + builder := catalogueManifest(t, "build-agent") + seat, _ := SeatNamed("npm-package-registry") + var requires bool + for _, r := range builder.Requires { + requires = requires || r == seat.Delivers + } + if !requires { + t.Fatalf("the builder does not require %q: %v", seat.Delivers, builder.Requires) + } + if builder.Binds[seat.Delivers] == "" { + t.Errorf("the builder is not told where the registry is: binds %v", builder.Binds) + } + for _, r := range builder.Resources { + if fmt.Sprint(r["id"]) == "package-binding" { + t.Fatal("the builder carries its own package binding beside the one the mesh resolves") + } + } +} + +// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser — +// which refuses a delivering seat claimed by a module that does not provide what it delivers. +func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) { + forge := catalogueManifest(t, "gitea") + holds := map[string]bool{} + for _, c := range forge.Claims { + holds[c.Name] = true + } + for _, seat := range []string{"npm-package-registry", "git"} { + if !holds[seat] { + t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims) + } + } + git := ServedOn(forge, "git", nil) + if git["scheme"] != "http" || git["port"] != float64(3000) { + t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git) + } + npm := ServedOn(forge, "npm-package-registry", nil) + if npm["npm-path"] != "/api/packages/novox/npm/" { + t.Errorf("gitea no longer says where its npm registry is: %v", npm) + } +} + +// **And the forge's own address follows it**, composed from the manifest in the catalogue beside +// this checkout (novox/hq 04-ISSUES/088). +// +// The forge is reached a third way that neither test above covers: by its own code, over the +// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the +// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is +// wrong on every node whose assignment differs, and wrong for a second reason on a node given the +// port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves +// in an `env` at all is a declaration, not a manifest. +func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { + forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea"))) + if err != nil { + t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) + } + r := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org", + Modules: []Manifest{forge, theRuntime(t)}, Needs: []Needed{ + {Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1", + Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"}, + {Name: "route", For: "gitea", From: "anchor"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"}, + }} + + // The number this node was given for the forge — the one the machine it is about to run on + // already publishes. + out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}}, + Given: map[string]map[int]int{"gitea": {3000: 2999}}, + }) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + + // What the machine publishes, and what the forge's sidecar is told to dial: one number. + server := fileNamed(out, "gitea.server") + if server == nil { + t.Fatalf("the forge's own container is not in the declaration: %v", out) + } + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") { + t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"]) + } + // The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there. + runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if runtime == nil { + t.Fatalf("the node's runtime is not in the declaration: %v", ids(out)) + } + env, _ := runtime["env"].(map[string]string) + var given map[string]map[string]string + if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil { + t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv]) + } + if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" { + t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+ + "whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"]) + } +} + +// gitea's own sshd is unmodified — the module's own internal port is 22, the number in +// `listens`, the same convention every other module in the catalogue uses (its internal port, +// not an invented identity). Composed from the manifest in the catalogue beside this checkout, +// because what the mesh publishes is a fact about what the module actually writes. +func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any { + t.Helper() + forge := catalogueManifest(t, "gitea") + resolved, err := forge.Resolve(forgeBuilt(forge)) + if err != nil { + t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) + } + r := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org", + Modules: []Manifest{resolved, theRuntime(t)}, Needs: []Needed{ + {Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1", + Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"}, + {Name: "route", For: "gitea", From: "anchor"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"}, + }} + givenPorts := map[int]int{3000: 3000} + for k, v := range given { + givenPorts[k] = v + } + out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}}, + Ports: map[string]map[int]int{"gitea": givenPorts}, + Given: map[string]map[int]int{"gitea": given}, + }) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + server := fileNamed(out, "gitea.server") + if server == nil { + t.Fatalf("the forge's own container is not in the declaration: %v", out) + } + return server +} + +// **The forge publishes ssh at the mesh's own fixed convention by default** (novox/hq ADR 0100). +// +// `222` is the mesh's own public convention for the forge's ssh, written directly in the +// manifest's `ports` — every node the forge has run on used the same number, so it needs no +// per-node setting to reach it. +func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) { + forge := catalogueManifest(t, "gitea") + // Nothing was given — no node moved this port — which is the ordinary answer: the mesh only + // reports what a setting moved, and the manifest's own `222:22` needs no move to be reached. + given, err := GivenPorts(forge, nil) + if err != nil { + t.Fatalf("the forge's ssh port cannot be given on a node: %v", err) + } + if len(given) != 0 { + t.Fatalf("nothing moved the forge's ssh port, yet it was given %v", given) + } + server := declaredGiteaSsh(t, given) + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") { + t.Fatalf("the forge is published on %v, not its own fixed convention", server["ports"]) + } +} + +// **A node whose predecessor served git on a different number can still be told to leave it +// there.** The setting names the port the module itself listens on — 22, gitea's own sshd, the +// same number `listens` uses — not the mesh's own default machine-side number, so moving it does +// not require guessing what the manifest happens to default to. +func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) { + forge := catalogueManifest(t, "gitea") + given, err := GivenPorts(forge, []Layer{{From: "anchor", + Placement: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}}) + if err != nil { + t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err) + } + if want := map[int]int{22: 9022}; !reflect.DeepEqual(given, want) { + t.Fatalf("the forge was given %v, and the setting named %v", given, want) + } + server := declaredGiteaSsh(t, given) + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "9022:22") || + strings.Contains(published, "222:22") { + t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"]) + } +} + +// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the +// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third +// artifact does not break every forge test again. +func forgeBuilt(forge Manifest) []Built { + var out []Built + for _, a := range forge.Build.Artifacts { + out = append(out, Built{Name: a.Name, Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest}) + } + return out +} + +// catalogueResolved is a catalogue module as the mesh holds it once built: the manifest as written, resolved against +// what the build produced besides the archive its templates are packed into, which carries the text read back from the +// module's files/ directory as the real build reads it (novox/hq ADR 0349 §2) — so a module whose file text moved into +// templates composes with it, and one that did not is resolved as it was. +func catalogueResolved(t *testing.T, module string, built ...Built) Manifest { + t.Helper() + root, dir := moduleTree(t, module) + m, err := ReadTreeManifestWritten(root, dir) + if err != nil { + t.Fatalf("%s does not parse:\n%v", module, err) + } + if name := TemplatesArtifact(m); name != "" { + read, err := ReadTemplates(m, dir) + if err != nil { + t.Fatalf("%s: %v", module, err) + } + // By the path inside the archive, as the build reads it back, not by the path in the module's directory. + texts := map[string]string{} + for path, text := range read { + texts[strings.TrimPrefix(path, m.TemplatesDir()+"/")] = text + } + built = append(built, Built{Name: name, Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + module + "/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest, + Texts: texts}) + } + held, err := m.Resolve(built) + if err != nil { + t.Fatalf("%s does not resolve against its own build: %v", module, err) + } + return held +} diff --git a/go/catalogue/generated_collision_test.go b/go/catalogue/generated_collision_test.go new file mode 100644 index 0000000..45fcabe --- /dev/null +++ b/go/catalogue/generated_collision_test.go @@ -0,0 +1,85 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule +// every module is — no two modules on a machine declare one path, unit or name (a package may be declared by +// several, novox/hq ADR 0303). The +// private network once declared the container runtime's file and service beside the runtime's own +// module, and nothing refused it, because the collision check only ever saw catalogue manifests. + +func runtimesOwn() Manifest { + return Manifest{Module: "docker", Resources: []map[string]any{ + {"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "into": "json", + "content": `{"live-restore": true}`}, + {"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", + "reload-on": []any{"daemon"}}, + }} +} + +func TestAGeneratedResourceCollidingWithAModulesIsRefused(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")}, + runtimesOwn(), + }} + _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": reloading{}}}) + if err == nil { + t.Fatal("a generated resource declaring the runtime's file beside the runtime's module was accepted") + } + for _, want := range []string{"mesh-network", "docker", "/etc/docker/daemon.json", "docker.service"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestAGeneratedResourceBesideAModulesOwnIsComposed(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")}, + runtimesOwn(), + }} + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}}) + if err != nil { + t.Fatalf("disjoint resources were refused: %v", err) + } + if fileNamed(out, "docker.daemon") == nil { + t.Fatalf("the runtime's own file is missing: %v", out) + } + // And a machine not on the network yet generates nothing, which collides with nothing. + if _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": &fake{}}}); err != nil { + t.Fatalf("a machine off the network was refused: %v", err) + } +} + +// The catalogue's container runtime module states the mesh's registry itself (ADR 0222). +func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) { + docker := catalogueManifest(t, "docker") + r := Resolution{Node: "workstation", Modules: []Manifest{docker}} + out, err := r.Declaration(Rendering{ + SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}, + }) + if err != nil { + t.Fatal(err) + } + daemon := fileNamed(out, "docker.daemon") + if daemon == nil || daemon["into"] != "json" { + t.Fatalf("the runtime's file is not written into: %v", daemon) + } + content, _ := daemon["content"].(string) + if !strings.Contains(content, `"insecure-registries": ["anchor.internal:5100"]`) || + !strings.Contains(content, `"live-restore": true`) { + t.Fatalf("the runtime's file says %q", content) + } + + out, err = r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + if content, _ := fileNamed(out, "docker.daemon")["content"].(string); strings.Contains(content, "insecure-registries") { + t.Fatalf("with no store on the network, the runtime is told %q", content) + } +} diff --git a/go/catalogue/hostname_seat_test.go b/go/catalogue/hostname_seat_test.go new file mode 100644 index 0000000..c151efa --- /dev/null +++ b/go/catalogue/hostname_seat_test.go @@ -0,0 +1,98 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0223 part 3: a machine's names are one seat's. The `hosts` module holding +// `node-hosts-file` became `hostname` holding `node-hostname`, which writes /etc/hostname beside the +// machine's own lines in /etc/hosts. The seat was renamed (ADR 0122), so what claims the old name — a +// manifest registered before the rename — still holds the one seat. + +func withHostnameAlias(t *testing.T) { + t.Helper() + was := aliases + t.Cleanup(func() { aliases = was }) + UseAliases(map[string]string{"node-hosts-file": "node-hostname"}) +} + +func TestTheHostsFilesFormerNameResolvesToTheHostnameSeat(t *testing.T) { + if _, known := SeatNamed("node-hostname"); !known { + t.Fatal("node-hostname is not in the mesh's set") + } + withHostnameAlias(t) + seat, known := SeatNamed("node-hosts-file") + if !known || seat.Name != "node-hostname" || seat.Scope != ScopeNode { + t.Fatalf("the former name did not resolve: %+v %v", seat, known) + } + var verbs []string + for _, v := range seat.Serves { + verbs = append(verbs, v.Name) + } + if strings.Join(verbs, " ") != "entries add remove" { + t.Errorf("the renamed seat serves %v; its verbs are unchanged", verbs) + } +} + +// One seat under either name: the module registered before the rename and the one after cannot both +// hold it on one machine. +func TestTheOldAndTheNewClaimantAreOneSeatOnAMachine(t *testing.T) { + withHostnameAlias(t) + cat := shelf( + mod("hosts", nil, nil, nil, Claim{Name: "node-hosts-file"}), + mod("hostname", nil, nil, nil, Claim{Name: "node-hostname"}), + ) + _, err := Resolve(cat, []string{"hosts", "hostname"}, workstation(), World{}) + if err == nil || !strings.Contains(err.Error(), "node-hostname") { + t.Errorf("hosts and hostname both held the machine's names on one machine: %v", err) + } + if _, err := Resolve(cat, []string{"hosts"}, workstation(), World{}); err != nil { + t.Errorf("a machine still assigned hosts under the old name does not resolve: %v", err) + } +} + +// The catalogue's module: /etc/hostname is the operator's `hostname` setting. Without it the module is +// left out, naming the key — the mesh never renames a machine on its own — and a mesh-wide setting +// naming ${machine:name} gives every machine its mesh name. +func TestTheMachinesNameIsItsSetting(t *testing.T) { + cat := map[string]Manifest{"hostname": catalogueManifest(t, "hostname")} + got, err := Resolve(cat, []string{"hostname"}, Node{Name: "ace", At: "ace.internal"}, World{}) + if err != nil { + t.Fatal(err) + } + machines := map[string]string{"ace.internal": "10.42.0.2"} + nameOf := func(settings SettingsBy) (string, string) { + t.Helper() + composed, err := got.Compose(Rendering{Names: machines, Machines: machines, Suffix: "internal", + Settings: settings}) + if err != nil { + t.Fatal(err) + } + if why := composed.LeftOut["hostname"]; why != "" { + return "", why + } + for _, r := range composed.Resources { + if r["path"] == "/etc/hostname" { + return r["content"].(string), "" + } + } + t.Fatal("no /etc/hostname composed") + return "", "" + } + + if _, why := nameOf(nil); !strings.Contains(why, "hostname") { + t.Errorf("with no setting the machine's name was written, or left out for another reason: %q", why) + } + if name, why := nameOf(SettingsBy{"hostname": {{From: "ace", Values: map[string]any{"hostname": "Ace"}}}}); name != "Ace\n" { + t.Errorf("the operator's name for the machine gave %q (%s)", name, why) + } + mesh := Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}} + if name, why := nameOf(SettingsBy{"hostname": {mesh}}); name != "ace\n" { + t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why) + } + node := Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}} + if name, why := nameOf(SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" { + t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why) + } +} diff --git a/go/catalogue/logs_test.go b/go/catalogue/logs_test.go new file mode 100644 index 0000000..76e7ee0 --- /dev/null +++ b/go/catalogue/logs_test.go @@ -0,0 +1,68 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap +// in MiB; the default cap where none is said. +func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` + + `"logs":["changes",{"name":"moves","max-mib":2048}]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.Logs) != 2 || m.Logs[0].Name != "changes" || m.Logs[1].Name != "moves" { + t.Fatalf("logs not read: %+v", m.Logs) + } + if m.Logs[0].Cap() != LogDefaultMiB || m.Logs[0].Cap() != 1024 || m.Logs[1].Cap() != 2048 { + t.Fatalf("caps read as %d and %d", m.Logs[0].Cap(), m.Logs[1].Cap()) + } + out, _ := json.Marshal(m.Logs) + if string(out) != `["changes",{"name":"moves","max-mib":2048}]` { + t.Fatalf("written back as %s", out) + } + for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} { + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { + t.Errorf("%s is inside the caps and was refused: %v", edge, err) + } + } +} + +// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a +// field the mesh does not know, or kept by a module whose own name is not one plain token. +func TestALogIsNamedLocallyAndCapped(t *testing.T) { + for _, c := range []struct{ manifest, says string }{ + {`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, + {`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`}, + {`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`}, + {`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, + {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`}, + {`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps. +func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) { + var problems []string + for _, m := range theCatalogue(t) { + problems = append(problems, LogProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n ")) + } +} diff --git a/go/catalogue/mounts_test.go b/go/catalogue/mounts_test.go new file mode 100644 index 0000000..f929a5d --- /dev/null +++ b/go/catalogue/mounts_test.go @@ -0,0 +1,104 @@ +package catalogue + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// A bind mount the module never declared is refused where it is written (novox/hq 04-ISSUES/026, +// ADR 0091). The container runtime creates a missing bind source itself, as root, with a mode it +// picks, so the module's own owner and mode never reach the directory holding its data, and the +// rule that keeps data when a module goes away (ADR 0030) does not cover it. + +const aContainerMounting = `{"id":"server","type":"container","name":"store","image":"x@sha256:` + + `0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}` + +func manifestMounting(from string, beside string) []byte { + res := aContainerMounting + if beside != "" { + res = beside + "," + res + } + return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`) +} + +func TestAMountNothingDeclaresIsRefused(t *testing.T) { + _, err := ParseManifest(manifestMounting("/services/store/data", "")) + if err == nil { + t.Fatal("a container mounting a path no resource declares was accepted; the runtime " + + "would create it as root and the module's owner and mode would never apply") + } + if !strings.Contains(err.Error(), "/services/store/data") { + t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err) + } +} + +// Declaring it is enough — including declaring the directory above it. +func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { + _, err := ParseManifest(manifestMounting("/services/store/data", + `{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`)) + if err != nil { + t.Fatalf("a module that said where its data lives was refused anyway: %v", err) + } +} + +// The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the +// module would then own. +func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`)) + if err != nil { + t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err) + } +} + +// The machine's facility — the container runtime's socket — is granted by the capability that names +// it, and declaring it as the module's own directory would be a lie the host would act on. This is +// the case the first version of this check refused, and was withdrawn for: the builder. +func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) { + granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` + + strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}` + if _, err := ParseManifest([]byte(granted)); err != nil { + t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err) + } + if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil { + t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted") + } +} + +// **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue +// is already breaking. +func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { + files, _ := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) + if len(files) == 0 { + t.Fatal("no manifests in the catalogue, so this proved nothing") + } + root := beside.Dir(t, "mesh-catalog") // once: it copies the captured tree + for _, file := range files { + if _, err := ReadTreeManifest(root, filepath.Dir(file)); err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(file)), err) + } + } +} + +// Where a bound fact lands is the mesh's file too, and a container may mount it directly. +func TestAMountOfABoundFactIsAccepted(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` + + `"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` + + strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`)) + if err != nil { + t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err) + } +} + +// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR +// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it. +func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`)) + if err != nil { + t.Fatalf("a filter module mounting its own filter file was refused: %v", err) + } +} diff --git a/go/catalogue/no_subjects_test.go b/go/catalogue/no_subjects_test.go new file mode 100644 index 0000000..23c46c3 --- /dev/null +++ b/go/catalogue/no_subjects_test.go @@ -0,0 +1,124 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// **A manifest holds no subject** (novox/hq design 29 §1). +// +// A module names its events, tools and seats locally, and the mesh derives where they land. The +// property that buys: reorganise the subject space and every manifest in the catalogue is still +// correct. It holds today by construction — nothing reads a subject from a manifest — and a rule +// held by construction is one a later field breaks quietly, with the symptom appearing as a +// permission that does not match a subject rather than as a manifest that was wrong. +func TestNoManifestContainsASubject(t *testing.T) { + root := beside.Catalogue(t) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + + // Anything in the mesh's own subject space, and anything shaped like a wire address. + subject := regexp.MustCompile(`^(mesh|\$JS)\.[a-zA-Z0-9_*>.-]+$`) + + var found []string + var walk func(module string, path string, v any) + walk = func(module, path string, v any) { + switch t := v.(type) { + case string: + if subject.MatchString(t) { + found = append(found, module+" "+path+" = "+t) + } + case map[string]any: + for k, inner := range t { + walk(module, path+"."+k, inner) + } + case []any: + for _, inner := range t { + walk(module, path+"[]", inner) + } + } + } + + checked := 0 + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) + if err != nil { + continue + } + var m any + if err := json.Unmarshal(raw, &m); err != nil { + t.Errorf("%s: %v", e.Name(), err) + continue + } + checked++ + walk(e.Name(), "", m) + } + if checked == 0 { + t.Fatal("no manifests read, so this proved nothing") + } + if len(found) > 0 { + t.Errorf("a manifest names a subject, so reorganising the subject space would mean "+ + "editing the catalogue:\n %s", strings.Join(found, "\n ")) + } + t.Logf("%d manifests hold no subject", checked) +} + +// **Every module's event names are what design 29 says, across the whole catalogue.** +// +// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds +// no subject, and every manifest in the catalogue still held the old bus's routing key, which +// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services +// started and none of them reacted. This is the check that was missing. +func TestEveryManifestsEventNamesAreLocal(t *testing.T) { + manifests := theCatalogue(t) + + var problems []string + for _, m := range manifests { + problems = append(problems, EventProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s", + len(problems), strings.Join(problems, "\n ")) + } +} + +// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration +// parses one. +func theCatalogue(t *testing.T) []Manifest { + t.Helper() + root := beside.Catalogue(t) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + var out []Manifest + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) + if err != nil { + continue + } + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", e.Name(), err) + } + out = append(out, m) + } + if len(out) == 0 { + t.Fatalf("no manifests under %s, so this proved nothing", root) + } + return out +} diff --git a/go/catalogue/node_resolver_test.go b/go/catalogue/node_resolver_test.go new file mode 100644 index 0000000..b5cec6d --- /dev/null +++ b/go/catalogue/node_resolver_test.go @@ -0,0 +1,216 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires +// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it. + +// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder +// exists only once the module serving them does, so nothing has to be optional while it catches up. +func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) { + s, ok := SeatNamed(ResolverSeat) + if !ok { + t.Fatalf("%s is not in the mesh's set", ResolverSeat) + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated { + t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Optional { + t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name) + } + if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 { + t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name) + } + } + if strings.Join(got, " ") != "routes route unroute" { + t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got) + } + // The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one. + for _, v := range s.Serves { + if strings.Contains(strings.ToLower(v.Description), "forti") { + t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description) + } + } +} + +// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the +// machine's own address. What is under test is the controller's rule, not the catalogue's module. +func localResolver() Manifest { + return Manifest{Module: "local-resolver", Version: "1", + Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, + Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}} +} + +func splitDNSLaptop() Node { + return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}} +} + +// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming +// the machine's own address; the uplink's holder composes everything else it declares, and not that file. +func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) { + shelf := resolverShelf(t) + shelf["local-resolver"] = localResolver() + got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + var files []string + for _, r := range out { + if r["path"] == ResolverFile { + files = append(files, r["id"].(string)) + } + } + if strings.Join(files, " ") != "local-resolver.fact-resolvers" { + t.Fatalf("the resolver file is composed as %v; once, the resolver's", files) + } + content := byID(out)["local-resolver.fact-resolvers"]["content"].(string) + if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") { + t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content) + } + // The uplink's holder is still composed: only the one file moved. + uplinkComposed := false + for _, r := range out { + if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") { + uplinkComposed = true + } + } + if !uplinkComposed { + t.Errorf("the uplink's holder composed nothing once the resolver was held") + } +} + +// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's +// resolvers (ADR 0223) — every machine but the one that requires split-dns. +func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile { + t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r) + } +} + +// Only the uplink's holder steps back. A third module rendering or declaring the file beside the +// resolver's is two owners of one path, refused as before; and a module rendering it without holding +// the seat is not the resolver, so the uplink's holder does not step back for it. +func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) { + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 { + t.Errorf("the uplink's holder and the resolver's were refused together: %v", p) + } + other := Manifest{Module: "other", Version: "1", + Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}} + if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 { + t.Error("a third module wrote the resolver file beside the resolver's") + } + if p := checkResources([]Manifest{uplink, other}); len(p) == 0 { + t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder") + } + declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{ + {"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}} + if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 { + t.Error("a module declaring the resolver file was let beside the resolver's") + } + // What steps back is the one file: the uplink's other facts stay. + uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"} + if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" { + t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts) + } + if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 { + t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts) + } +} + +// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own +// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides +// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls +// the resolver in. Skipped while the catalogue has no holder. +func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) { + held := 0 + for _, m := range theCatalogue(t) { + if !holdsSeat(m, ResolverSeat) { + continue + } + held++ + f, ok := m.Facts["resolvers"] + if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") { + t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f) + } + provides := false + for _, o := range m.Provides { + if o.Name == "split-dns" && o.Reach == ReachMachine { + provides = true + } + } + if !provides { + t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat) + } + } + if held == 0 { + t.Skip("no module of the catalogue holds node-resolver yet") + } +} + +// The catalogue's systemd-resolved, composed on a machine beside its uplink: the resolver file names the +// machine's own private address alone, its kept copy is the same file, and resolved is given every mesh +// resolver as its default route and the private address to listen on. Skipped without the catalogue. +func TestTheCataloguesResolverComposesOnAMachine(t *testing.T) { + shelf := resolverShelf(t) + shelf["systemd-resolved"] = catalogueManifest(t, "systemd-resolved") + got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "systemd-resolved"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + ids := byID(out) + resolv, _ := ids["systemd-resolved.fact-resolvers"]["content"].(string) + kept, _ := ids["systemd-resolved.fact-kept"]["content"].(string) + if !strings.Contains(resolv, "\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n") || resolv != kept { + t.Errorf("the resolver file does not name this machine's own resolver alone, or its copy differs:\n%s", resolv) + } + if ids["systemd-networkd.fact-resolvers"] != nil { + t.Error("the uplink's holder still writes the resolver file beside the resolver's") + } + conf, _ := ids["systemd-resolved.fact-resolved"]["content"].(string) + for _, want := range []string{"\nDNS=10.42.0.1 10.42.0.3 \n", "\nDNSStubListenerExtra=10.42.0.2\n", "\nFallbackDNS=\n"} { + if !strings.Contains(conf, want) { + t.Errorf("resolved's drop-in lacks %q:\n%s", want, conf) + } + } + if s, _ := ids["systemd-resolved.fact-suffix"]["content"].(string); s != "internal\n" { + t.Errorf("the mesh's domain is rendered as %q", s) + } +} diff --git a/go/catalogue/provided_test.go b/go/catalogue/provided_test.go new file mode 100644 index 0000000..a4f9413 --- /dev/null +++ b/go/catalogue/provided_test.go @@ -0,0 +1,144 @@ +package catalogue_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/overlay" +) + +// The manifests the control plane actually ships, resolved. +// +// Written because the earlier tests built their own manifests and passed while the real one was +// missing a claim — a whole mechanism could have been absent from what ships and every test would +// still have been green. + +func provided(t *testing.T) map[string]catalogue.Manifest { + t.Helper() + out := map[string]catalogue.Manifest{} + for _, raw := range []map[string]any{ + overlay.Manifest(), + } { + b, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.ParseManifest(b) + if err != nil { + t.Fatalf("a manifest this control plane ships is not valid: %v", err) + } + out[m.Module] = m + } + return out +} + +func TestTheShippedPrivateNetworkResolvesOnItsOwn(t *testing.T) { + // **Assigned directly** (novox/hq ADR 0226): the `networking` bundle that required it is + // retired, so the private network's own module is what a machine is given, and it must need + // nothing the control plane does not ship beside it. + got, err := catalogue.Resolve(provided(t), []string{overlay.Name}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err != nil { + t.Fatalf("assigning %s does not work out of the box: %v", overlay.Name, err) + } + if len(got.Modules) != 1 || got.Modules[0].Module != overlay.Name { + t.Fatalf("assigning %s brought %v", overlay.Name, got.Modules) + } + + // **The network writes no names** (novox/hq ADR 0199): /etc/hosts is the hosts seat holder's + // file, and the mesh's resolver answers the machines' names. No fact of the network's module + // may name that file. + for _, m := range got.Modules { + for name, f := range m.Facts { + if f.Path == "/etc/hosts" { + t.Fatalf("the network's provider still writes /etc/hosts, as its %q fact", name) + } + } + } +} + +func TestTheControlPlaneShipsNoNetworkingBundle(t *testing.T) { + // ADR 0226: one module for the one private network. A bundle shipped beside it again would be + // a second name every machine carries for the same thing. + shipped := provided(t) + if len(shipped) != 1 { + t.Fatalf("the control plane ships %d modules, want only %s", len(shipped), overlay.Name) + } + if _, ok := shipped["networking"]; ok { + t.Fatal("the retired networking bundle is shipped again") + } +} + +func TestARefusalForWantOfThePrivateNetworkNamesItsModule(t *testing.T) { + // The hint in a refusal is a string in the resolver rather than an import of this package. It + // named `networking` until ADR 0226; a hint naming a module nobody ships sends a person to + // assign something that does not exist. + shelf := map[string]catalogue.Manifest{ + "app": {Module: "app", Version: "1", Requires: []string{"postgres-database"}}, + "postgres": {Module: "postgres", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")}, + } + _, err := catalogue.Resolve(shelf, []string{"app"}, catalogue.Node{Name: "workstation"}, + catalogue.World{Offered: map[string][]catalogue.Provider{ + "postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app off the private network was pointed at a database on it") + } + if !strings.Contains(err.Error(), "assign "+overlay.Name) { + t.Fatalf("the refusal does not name the private network's module %s: %v", overlay.Name, err) + } +} + +func TestAnotherVPNIsChosenByAssigningItInstead(t *testing.T) { + // What the bundle was for, kept without it: a machine given another VPN answers + // private-network from that VPN, and WireGuard is not dragged in by anything. + shipped := provided(t) + shelf := withTailscale(shipped) + shelf["needs-network"] = catalogue.Manifest{Module: "needs-network", Version: "1", + Requires: []string{overlay.Requirement}} + got, err := catalogue.Resolve(shelf, []string{"needs-network", "tailscale"}, + catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err != nil { + t.Fatalf("choosing another VPN was refused: %v", err) + } + for _, m := range got.Modules { + if m.Module == overlay.Name { + t.Fatalf("the other VPN was chosen and WireGuard came anyway: %v", got.Modules) + } + } +} + +func TestTwoVPNsAssignedTogetherStillCollide(t *testing.T) { + // The claim still guards the case it was always for: both assigned EXPLICITLY, which is a + // machine with two private networks and a coin toss about which one a peer reaches it on. + shipped := provided(t) + _, err := catalogue.Resolve( + withTailscale(shipped), + []string{overlay.Name, "tailscale"}, + catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err == nil { + t.Fatal("a machine was given two private networks and nobody was told") + } + if !strings.Contains(err.Error(), overlay.TheNetwork) { + t.Fatalf("the refusal does not say what collided: %v", err) + } +} + +// The names-need-addressing test went with the names module: names are a fact now, and a machine +// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same +// protection, enforced where the file is written rather than by a provision refusing. + +func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest { + out := map[string]catalogue.Manifest{} + for k, v := range shelf { + out[k] = v + } + // Deliberately without name-resolution of its own, which is the case that used to install + // both VPNs: the names then needed the mesh's addressing, and only WireGuard has it. + out["tailscale"] = catalogue.Manifest{ + Module: "tailscale", Version: "1", + Provides: catalogue.Offers(overlay.Requirement), + Claims: []catalogue.Claim{{Name: overlay.TheNetwork, Scope: catalogue.ScopeNode}}, + } + return out +} diff --git a/go/catalogue/public_issuer_test.go b/go/catalogue/public_issuer_test.go new file mode 100644 index 0000000..e80ddfb --- /dev/null +++ b/go/catalogue/public_issuer_test.go @@ -0,0 +1,122 @@ +package catalogue + +import ( + "os" + "path/filepath" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// The public issuer is the proxy's own fact (novox/hq ADR 0226), and the folding of it must not +// move the proxy's account. +// +// route-proxy keeps each ACME authority's account and certificates in a directory named after a +// digest of the directory URL and of the root bundle its `trust` container copies in +// (examples/route-proxy, forThisAuthority). Until ADR 0226 the URL was rendered from a binding to +// `public-acme`'s `acme-ca`, spelled with the port. A URL spelled any other way — even the same +// authority without `:443` — or a root bundle from another image is a new authority to the proxy: +// a new account, and every routed name ordered again, on two machines at once, against Let's +// Encrypt's rate limits. So what the module now states is held to exactly what the binding rendered. + +// renderedBeforeTheFold is route-proxy's acme.env as the binding to public-acme rendered it on every +// machine running the proxy, read from the controller's plan on 2026-10-06. +const renderedBeforeTheFold = "ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory\n" + + "ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443\n" + + "ACME_ROOTS_PATH=\n" + +// trustImage is the image whose system bundle becomes the public root the account directory is +// named after. Moving it renames that directory. +const trustImage = "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + +func TestRouteProxyKeepsItsPublicAccountDirectory(t *testing.T) { + m := catalogueManifest(t, "route-proxy") + for _, r := range m.Requires { + if r == "acme-ca" { + t.Fatal("route-proxy still asks for acme-ca; the public issuer is its own fact (ADR 0226)") + } + } + + // As a build would leave it: each artifact a container names resolved to an image. Which image + // does not matter to the file checked here. + for _, res := range m.Resources { + if artifact, ok := res["artifact"].(string); ok { + delete(res, "artifact") + res["image"] = "registry.invalid/route-proxy/" + artifact + + "@sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + } + r := Resolution{ + Node: "anchor", + Modules: []Manifest{m}, + Needs: []Needed{{ + Name: "internal-acme-ca", From: "home", At: "home.internal", For: "route-proxy", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + // Its own broker credential, sealed as the mesh would; what it is does not matter here. + out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{ + "route-proxy": {"broker": "sealed"}}}) + if err != nil { + t.Fatalf("route-proxy could not be composed for a machine: %v", err) + } + env := fileNamed(out, "route-proxy.acme-env") + if env == nil { + t.Fatalf("nothing writes the public issuer's environment: %v", out) + } + if got, _ := env["content"].(string); got != renderedBeforeTheFold { + t.Fatalf("acme.env changed, which moves the proxy's account and reorders every certificate:\n"+ + "got %q\nwant %q", got, renderedBeforeTheFold) + } + if fileNamed(out, "route-proxy.bound-acme-ca") != nil { + t.Error("a binding to acme-ca is still written") + } + + var trust string + if m.Build != nil { + for _, a := range m.Build.Artifacts { + if a.Name == "trust" { + trust = a.From + } + } + } + if trust != trustImage { + t.Errorf("the trust image is %q, not %q: its system bundle is the public root the account "+ + "directory is named after, so moving it reorders every certificate. Move it only with a "+ + "plan for the account (ADR 0226)", trust, trustImage) + } +} + +// The modules ADR 0226 retired stay retired, and nothing asks for what they provided. A module is +// in the catalogue when its manifest is: a directory left behind with no manifest in it (a build's +// leftovers, untracked by git) is not a module, and failed this on a desktop (novox/hq issue 432). +func TestTheRetiredNetworkingModulesAreNotInTheCatalogue(t *testing.T) { + modules := beside.Catalogue(t) + for _, gone := range []string{"public-acme", "dhcpcd", "cloudflare-dns"} { + if _, err := os.Stat(filepath.Join(modules, gone, "module.json")); err == nil { + t.Errorf("%s is in the catalogue again; ADR 0226 retired it", gone) + } + } + entries, err := os.ReadDir(modules) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + raw, err := os.ReadFile(filepath.Join(modules, e.Name(), "module.json")) + if err != nil { + continue + } + m, err := ParseManifest(raw) + if err != nil { + continue // judged by the catalogue's own tests + } + for _, r := range m.Requires { + if r == "acme-ca" || r == "public-dns" { + t.Errorf("%s requires %q, which nothing in the catalogue provides since ADR 0226", + m.Module, r) + } + } + } +} diff --git a/go/catalogue/published_ports_test.go b/go/catalogue/published_ports_test.go new file mode 100644 index 0000000..48036d2 --- /dev/null +++ b/go/catalogue/published_ports_test.go @@ -0,0 +1,75 @@ +package catalogue + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A container publishes only a port its module declares (novox/hq issue 227). +// +// **The short form is a question the mesh answers.** `"80"` means *publish what the software +// calls 80*, and the mesh fills in the machine's half from the port it assigned +// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a +// container publishing a number that appears nowhere in `listens` gets no assignment, and +// `publishedOn` falls back to the number as written. It escapes to the machine. +// +// That is how the photo module asked for port 80 on the control node, where the reverse proxy +// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never +// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh +// gives them a machine port for it. The difference is the declaration, not the number. +// +// A mapping written the long way is a module pinning both halves on purpose and is left alone. +func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) { + root := catalogueRoot(t) + entries, err := os.ReadDir(filepath.Join(root, "modules")) + if err != nil { + t.Fatal(err) + } + var escaped []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json")) + if err != nil { + continue + } + m, err := ParseManifest(raw) + if err != nil { + // Whether every manifest parses is TestEveryCatalogueManifestParses's question. + continue + } + declared := map[int]bool{} + for _, l := range m.Listens { + declared[l.Port] = true + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + listed, _ := r["ports"].([]any) + for _, p := range listed { + written := strings.Split(fmt.Sprint(p), "/")[0] + if strings.Contains(written, ":") { + continue // pinned by hand, both halves, on purpose + } + port, err := strconv.Atoi(strings.TrimSpace(written)) + if err != nil || declared[port] { + continue + } + escaped = append(escaped, fmt.Sprintf( + "%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+ + "to assign, so %d reaches the machine as written", + m.Module, r["id"], port, m.Module, port)) + } + } + } + if len(escaped) > 0 { + t.Fatalf("a container may publish only a port its module declares:\n - %s", + strings.Join(escaped, "\n - ")) + } +} diff --git a/go/catalogue/reads_test.go b/go/catalogue/reads_test.go new file mode 100644 index 0000000..69f314f --- /dev/null +++ b/go/catalogue/reads_test.go @@ -0,0 +1,137 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// novox/hq ADR 0310 §1, §2: node-reads is a node seat, and its verbs say the commands they replace, so +// mesh_search finds them by `cat`, `grep` and `getent passwd` (ADR 0245 §3). +func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) { + s, ok := SeatNamed(ReadsSeat) + if !ok { + t.Fatalf("the mesh defines no seat %s", ReadsSeat) + } + if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "0310") { + t.Errorf("%s: scope %q, delivers %q, decision %q", ReadsSeat, s.Scope, s.Delivers, s.Decision) + } + replaced := map[string]string{} + for _, v := range s.Serves { + if v.Optional { + t.Errorf("%s.%s is optional: a new seat's holder serves every verb", ReadsSeat, v.Name) + } + if len(v.Replaces) == 0 { + t.Errorf("%s.%s says nothing it replaces", ReadsSeat, v.Name) + } + for _, c := range v.Replaces { + replaced[c] = v.Name + } + } + for command, verb := range map[string]string{"cat": "read", "grep": "search", "getent passwd": "account", + "getent group": "account", "id": "account", "stat": "stat", "readlink": "readlink", "ls": "list"} { + if replaced[command] != verb { + t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], ReadsSeat, verb) + } + } +} + +// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would +// carry — as the operator's account — is refused at parse. +func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", + "claims": [{"name": "node-reads", "scope": "node"}]}`)) + if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { + t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) + } + if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads", + Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 { + t.Errorf("a holder running as its own account was refused: %v", p) + } +} + +// novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory +// of every module's own secret, every credential file and grant directory, the vault's directories and the +// place the operator-sealed secrets are kept — and no other module is told. +func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, + "reads": {"broker": "own"}, "desk": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + reads := aToolsModule(t, "reads", "tools/index.js") + reads.RunsAs, reads.SecretsOwner = "mesh-reads", "mesh-reads" + reads.Claims = []Claim{{Name: ReadsSeat}} + reads.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh-reads/broker"}} + desk := aToolsModule(t, "desk", "tools/index.js") + desk.RunsAs, desk.SecretsOwner = "desk", "desk" + desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}} + // The vault as the catalogue declares it: its directories have no path of their own, and the node places them. + vault, err := placedManifest(catalogueResolved(t, "mesh-vault", Built{Name: "code", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "mesh-vault/code/blobs/" + bundleDigest, Digest: bundleDigest}), with) + if err != nil { + t.Fatal(err) + } + app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"}, + OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}} + r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}} + owns, err := r.ownRuntimes(with) + if err != nil { + t.Fatal(err) + } + var given map[string]map[string]string + for _, p := range owns { + if p["id"] == "reads."+OwnRuntimeID() { + if err := json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &given); err != nil { + t.Fatal(err) + } + } + if p["id"] == "desk."+OwnRuntimeID() && strings.Contains(p["env"].(map[string]string)[RuntimeToolEnv], ReadsSecretPlaces) { + t.Error("a module not holding node-reads is told the node's secret places") + } + } + var places []string + if err := json.Unmarshal([]byte(given["reads"][ReadsSecretPlaces]), &places); err != nil { + t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given) + } + want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads", + "/var/lib/mesh-vault", "/var/lib/mesh/" + RuntimeModule} + if strings.Join(places, " ") != strings.Join(want, " ") { + t.Errorf("secret places:\n got %v\nwant %v", places, want) + } +} + +// The review of #257: a secret place the controller cannot resolve fails the composition, naming the module, +// rather than leaving the list one place shorter. +func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{ + {Module: "app", Version: "1", Keeps: "${dir:keep}/sealed"}, + }} + if _, err := r.secretPlaces(Rendering{}); err == nil || !strings.Contains(err.Error(), "app") { + t.Errorf("an unresolved place was dropped: %v", err) + } + r.Modules[0].Keeps = "relative/place" + if _, err := r.secretPlaces(Rendering{}); err == nil { + t.Error("a relative place was dropped") + } +} + +// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused. +func TestTheReadsAccountIsInNoGroup(t *testing.T) { + m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}} + if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") { + t.Errorf("an account in docker was not refused: %v", p) + } + m.Resources[0]["groups"] = []any{} + if p := ReadsHolderProblems(m); len(p) != 0 { + t.Errorf("an account in no group was refused: %v", p) + } +} diff --git a/go/catalogue/resolver_file_is_the_uplinks_test.go b/go/catalogue/resolver_file_is_the_uplinks_test.go new file mode 100644 index 0000000..dc2de44 --- /dev/null +++ b/go/catalogue/resolver_file_is_the_uplinks_test.go @@ -0,0 +1,59 @@ +package catalogue + +import ( + "reflect" + "testing" +) + +// novox/hq ADR 0223 part 2: /etc/resolv.conf belongs to the module holding node-uplink. The seat that +// wrote it, node-resolver-config, and ADR 0220's dependency of it on the uplink retire: one owner for +// the file, and it is the program that would otherwise rewrite it. + +func TestTheResolverConfigSeatIsGone(t *testing.T) { + if _, known := SeatNamed("node-resolver-config"); known { + t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file") + } + // An uplink's holder needs no seat beside it for the file: it is its own. + if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 { + t.Errorf("an uplink holder with nothing declared depends on %v", got) + } +} + +// The catalogue as it is: nothing claims the retired seat, and every manager the mesh knows holds the +// uplink and writes the resolver file; beside them only a holder of node-resolver does (ADR 0247). +func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T) { + cat := map[string]Manifest{} + for _, m := range theCatalogue(t) { + cat[m.Module] = m + } + if got := PossibleHolders(cat, "node-uplink"); !reflect.DeepEqual(got, uplinks) { + t.Errorf("node-uplink can be held by %v, not %v", got, uplinks) + } + for name, m := range cat { + for _, c := range m.Claims { + if c.Name == "node-resolver-config" { + t.Errorf("%s still claims node-resolver-config", name) + } + } + _, writes := m.Facts["resolvers"] + isUplink := false + for _, u := range uplinks { + isUplink = isUplink || u == name + } + for _, f := range m.Facts { + // Or the machine's own resolver's, where it is held (novox/hq ADR 0247): the uplink's holder + // steps back from the file there, and nowhere else. + if f.Path == "/etc/resolv.conf" && !isUplink && !holdsSeat(m, ResolverSeat) { + t.Errorf("%s writes /etc/resolv.conf and holds neither the uplink nor the machine's resolver", name) + } + } + for _, r := range m.Resources { + if r["path"] == "/etc/resolv.conf" { + t.Errorf("%s declares /etc/resolv.conf as a file of its own", name) + } + } + if isUplink && !writes { + t.Errorf("%s holds the uplink and does not write the resolver file", name) + } + } +} diff --git a/go/catalogue/resolver_manifests_test.go b/go/catalogue/resolver_manifests_test.go new file mode 100644 index 0000000..c3dfadd --- /dev/null +++ b/go/catalogue/resolver_manifests_test.go @@ -0,0 +1,327 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// The catalogue's resolver modules as they are, parsed by the real parser and composed as a +// machine would receive them (hal dnsmasq-app conversion, novox/hq 08-connectivity). +// +// The predecessor's resolver answered every name on a machine: the mesh's own itself, the rest +// forwarded to two fixed upstreams, with the machine's resolv.conf naming it alone and the +// container runtime pointed at its private-network address. These hold the mesh's modules to the +// same arrangement, and to the two things a resolver here must never do — read resolv.conf for +// its upstreams, or take an address systemd-resolved holds. + +// resolverShelf is the resolver, an uplink module that writes what the machine asks (novox/hq ADR +// 0223), and the container runtime beside something that answers `mesh-addressing`. +// The networking module that really does is composed in the controller and cannot be imported +// here, so a stand-in offers the same word; what is under test is the manifests, not the network. +func resolverShelf(t *testing.T) map[string]Manifest { + t.Helper() + shelf := map[string]Manifest{ + "net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}}, + } + for _, name := range []string{"dnsmasq", "systemd-networkd", "docker"} { + shelf[name] = catalogueManifest(t, name) + } + return shelf +} + +// twoMachines is what the control plane hands a rendering: internal names and their addresses. +var twoMachines = map[string]string{"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2"} + +// Its configuration forwards to the upstreams the predecessor's module shipped, and gets them from +// nowhere else: `no-resolv` is what makes the documented loop — the resolver finding its own +// address in resolv.conf and becoming its own upstream — impossible. +func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T) { + m := catalogueManifest(t, "dnsmasq") + var config string + for _, r := range m.Resources { + if r["id"] == "config" { + config, _ = r["content"].(string) + } + } + if config == "" { + t.Fatal("the resolver has no configuration file") + } + for _, want := range []string{ + "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", + // The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a + // member cannot reach what the mesh's names point at. + "\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", + + // No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199). + "\nno-hosts\n", + "\nconf-file=" + m.Facts["zones"].Path + "\n", + "\ndomain-needed\n", "\nbogus-priv\n", + "\nconf-file=" + m.Facts["node-zones"].Path + "\n", + } { + if !strings.Contains(config, want) { + t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config) + } + } + // Bound to each address once, at start, and never by bind-dynamic (novox/hq issue 348): bind-dynamic + // re-reads the machine's addresses on every change of any link and closes each listener that reading + // did not find, and a reading made while the container runtime tears down a bridge's virtual links + // fails, so the private address was left unbound for twenty minutes, the daemon running and silent. + // The catalogue's main binds once since mesh-catalog PR 161 (105cc7c0). + if !strings.Contains(config, "\nbind-interfaces\n") { + t.Errorf("the resolver's configuration does not bind its addresses once (bind-interfaces):\n%s", config) + } + if strings.Contains(config, "\nbind-dynamic\n") { + t.Errorf("the resolver's configuration says bind-dynamic, which closes its listeners when a bridge is "+ + "torn down (novox/hq issue 348):\n%s", config) + } + // By address and never by interface: dnsmasq admits a query by the interface it arrives on + // when told one, and a container's query to the private address arrives on the runtime's + // bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110). + for _, line := range strings.Split(config, "\n") { + if strings.HasPrefix(line, "interface=") { + t.Errorf("the resolver answers by interface, so a container's query on a bridge is dropped: %s", line) + } + } + // Not .53 or .54, which systemd-resolved holds; and not .55 any more, which was a convention + // beside the one every machine already followed — the predecessor's resolv.conf says .1. + for _, taken := range []string{"127.0.0.53", "127.0.0.54", "127.0.0.55"} { + if strings.Contains(config, "listen-address="+taken) { + t.Errorf("the resolver listens on %s", taken) + } + } + // Never a directory or a file the operator keeps: a line written for one machine's programs would + // become an answer for every node (ADR 0199). + for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} { + if strings.Contains(config, never) { + t.Errorf("the mesh's resolver still reads or listens on %q", never) + } + } + // And the file that decides what the machine asks names every one of the mesh's resolvers, by + // address, from the seat's holders, and no public one (ADR 0223): a resolver library that asks + // every listed server at once takes the first reply, and a public "no such name" for a mesh name + // won it. Written by every uplink module, since the uplink's holder owns the file. + for _, uplink := range uplinks { + fact, ok := catalogueManifest(t, uplink).Facts["resolvers"] + if !ok || fact.Path != "/etc/resolv.conf" { + t.Fatalf("%s's resolver file is not rendered from the roster: %+v", uplink, fact) + } + if !strings.Contains(fact.Template, `{{range index .Holders "mesh-dns-resolver"}}nameserver {{.Address}}`) { + t.Errorf("%s's resolv.conf does not list every holder of the mesh's resolver:\n%s", uplink, fact.Template) + } + for _, line := range strings.Split(fact.Template, "\n") { + if strings.HasPrefix(line, "nameserver ") && !strings.Contains(line, "{{") { + t.Errorf("%s's resolv.conf names a resolver of its own beside the mesh's: %s", uplink, line) + } + } + if !strings.Contains(fact.Template, "options timeout:1 attempts:2 edns0\n") { + t.Errorf("%s: a silent resolver is not passed over after one short wait:\n%s", uplink, fact.Template) + } + } +} + +// The resolver and what points the machine at it compose on one machine, and what arrives is the +// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on +// that file, the machine pointed at the resolver by address, and the runtime given no resolver of +// its own but kept running across a restart (ADR 0196). +func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd", "docker"}, + Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, "privileged": true, + "uplink-systemd-networkd": true}}, World{}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(named(got), " "), "net") { + t.Fatalf("the resolver's data is the mesh's addresses, and nothing answering them was taken: %v", named(got)) + } + out, err := got.Declaration(Rendering{ + // Names is every name the mesh serves; Machines is the subset that is a node (novox/hq + // issue 111) — the resolver's zones read only the second, and in this scenario the two + // happen to be the same map, since nothing routed is part of it. + Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}}, + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + + ids := byID(out) + zones := ids["dnsmasq.fact-node-zones"] + if zones == nil || zones["path"] != "/etc/mesh-resolver/nodes.conf" { + t.Fatalf("the resolver was not given the machines where its configuration reads them: %v", zones) + } + content, _ := zones["content"].(string) + for _, want := range []string{ + "local=/internal/", "address=/anchor.internal/10.42.0.1", "address=/laptop.internal/10.42.0.2", + } { + if !strings.Contains(content, want) { + t.Errorf("the machines file lacks %q:\n%s", want, content) + } + } + + service := ids["dnsmasq.service"] + if service == nil { + t.Fatal("no resolver service composed") + } + reflects := map[string]bool{} + for _, id := range service["restart-on"].([]any) { + reflects[id.(string)] = true + } + if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] { + t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"]) + } + if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") { + t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z) + } + + // The runtime's own file, written into (novox/hq ADR 0102) with one key, by the runtime's own + // module — a module does not write another software's configuration (issue 190): a restart keeps + // every container running. No `dns` — a container copies its machine's resolvers (ADR 0196), and + // neither the resolver nor what decides how the machine resolves writes the runtime's file. + if ids["dnsmasq.runtime-dns"] != nil { + t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"]) + } + for id := range ids { + if strings.HasPrefix(id, "systemd-networkd.runtime") { + t.Errorf("what the machine asks still writes the runtime's file: %s", id) + } + } + runtime := ids["docker.daemon"] + if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" { + t.Fatalf("live-restore is not written into the runtime's file: %v", runtime) + } + var keys map[string]any + if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil { + t.Fatalf("the runtime's keys are not JSON: %v", err) + } + // What the runtime is given is what its own module declares, and nothing a layer or another module + // adds. This used to say "live-restore and nothing else", to hold the line ADR 0196 drew when `dns` + // left the file; since the docker module gained log rotation as a preference (mesh-catalog #205 and + // #217, novox/hq issue 452) that frozen copy failed every check (novox/hq issue 498). So the keys are + // judged against the module's own daemon.json instead: live-restore is given, `dns` never is, and no + // key the module does not declare appears. + if keys["live-restore"] != true { + t.Errorf("the runtime is given %v; a restart must keep every container running (live-restore)", keys) + } + if _, ok := keys["dns"]; ok { + t.Errorf("the runtime is given a resolver of its own, %v; a container copies its machine's (ADR 0196)", keys["dns"]) + } + declared := daemonKeysDeclared(t) + for key := range keys { + if !declared[key] { + t.Errorf("the runtime is given %q, which the docker module does not declare: %v", key, keys) + } + } + // The log rotation preference, at its defaults, wherever the module declares it. The copy captured in + // testdata/beside predates it, so outside a merge check it is not there to judge. + if declared["log-driver"] { + opts, _ := keys["log-opts"].(map[string]any) + if keys["log-driver"] != "json-file" || opts["max-size"] != "100m" || opts["max-file"] != "10" { + t.Errorf("the runtime is given %v; its containers' logs are cut at 100m and ten are kept (novox/hq issue 452)", keys) + } + } + // The runtime is reloaded when that file changes, and never restarted: a restart stops every + // container on the machine (ADR 0102), and a reload is what turns live-restore on. + var reloaded bool + for _, r := range out { + if r["type"] != "service" || r["unit"] != "docker.service" { + continue + } + if _, restarts := r["restart-on"]; restarts { + t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r) + } + for _, on := range asStrings(r["reload-on"]) { + if on == "docker.daemon" { + reloaded = true + } + } + } + if !reloaded { + t.Errorf("the runtime is not reloaded when its file changes, so live-restore never takes effect") + } + + resolv := ids["systemd-networkd.fact-resolvers"] + if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\noptions ") { + t.Fatalf("the machine is not pointed at the resolver by address, and only at it: %v", resolv) + } +} + +// Two modules deciding what a machine asks are refused on one machine, as before — now that the file +// is the uplink's (novox/hq ADR 0223), by two things: a machine runs one network manager, and no other +// module may write the resolver file beside the uplink's, as a file or as a rendered fact. +func TestTwoThingsDecidingWhatAMachineAsksAreRefused(t *testing.T) { + shelf := resolverShelf(t) + shelf["networkmanager"] = catalogueManifest(t, "networkmanager") + node := Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, + "uplink-systemd-networkd": true, "uplink-networkmanager": true}} + _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "networkmanager"}, node, World{}) + if err == nil || !strings.Contains(err.Error(), "node-uplink") { + t.Fatalf("two network managers were both assigned to one machine: %v", err) + } + + // The second is made up: what is under test is that the resolver file has one owner, so any + // module asking the mesh to render it — or declaring it — will do. + for name, m := range map[string]Manifest{ + "a-rendered-one": {Module: "a-rendered-one", Version: "1", + Facts: map[string]RosterFile{"mine": {Path: "/etc/resolv.conf", Template: "nameserver 10.42.0.1\n"}}}, + "a-declared-one": {Module: "a-declared-one", Version: "1", Resources: []map[string]any{ + {"id": "mine", "type": "file", "path": "/etc/resolv.conf", "content": "nameserver 10.42.0.1\n"}}}, + } { + shelf := resolverShelf(t) + shelf[name] = m + _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", name}, node, World{}) + if err == nil || !strings.Contains(err.Error(), "/etc/resolv.conf") { + t.Errorf("%s wrote the resolver file beside the uplink's: %v", name, err) + } + } +} + +// A machine that is not on the private network has no address for the runtime to be pointed at. +// Refused where the module and the machine are both named, rather than a placeholder written into +// the runtime's file and read as an address. +func TestTheResolverOnAMachineOffTheNetworkIsRefused(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor"}, World{}) + if err != nil { + t.Fatal(err) + } + // Left out of the declaration and said, rather than composed listening nowhere: a module that + // cannot compose on a machine is kept as it is there, with the reason (hq ADR 0163). + composed, err := got.Compose(Rendering{Names: twoMachines, Suffix: "internal", + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}}) + if err == nil && !strings.Contains(composed.LeftOut["dnsmasq"], "${machine:address}") { + t.Fatalf("a machine off the network was composed a resolver, or left out for another reason: %v", + composed.LeftOut) + } + if err != nil && !strings.Contains(err.Error(), "${machine:address}") { + t.Fatalf("a machine off the network was refused for another reason: %v", err) + } +} + +// daemonKeysDeclared is the top-level keys of the docker module's own daemon.json, read from its +// manifest: what the runtime may be given (novox/hq issue 498). A placeholder sits inside a JSON +// string there, so the content parses as it is. +func daemonKeysDeclared(t *testing.T) map[string]bool { + t.Helper() + for _, r := range catalogueManifest(t, "docker").Resources { + if r["id"] != "daemon" { + continue + } + var keys map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &keys); err != nil { + t.Fatalf("the docker module's daemon.json is not JSON: %v", err) + } + declared := map[string]bool{} + for key := range keys { + declared[key] = true + } + return declared + } + t.Fatal("the docker module declares no daemon.json") + return nil +} diff --git a/go/catalogue/seat_data_test.go b/go/catalogue/seat_data_test.go new file mode 100644 index 0000000..3d2313e --- /dev/null +++ b/go/catalogue/seat_data_test.go @@ -0,0 +1,426 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "testing" +) + +// Defends novox/hq ADR 0255: a seat may receive a kind as data in its own shape, which the holder +// renders with its template; the holder places every combination of the shape's placing fields once; +// a contribution may be placed only where the machine reports a capability; and an offered kind makes +// no dependency on its seat. + +const barTemplate = `{{if eq .shows "battery"}}[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end}}{{else if eq .shows "command"}}[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{else if eq .shows "state"}}[[block]] +block = "custom" +command = {{quote (printf "show %s %s" .options.state (or .options.key .machine))}} +{{end}}` + +func barHolder() Manifest { + place := func(bar string) string { + return "own-first\n${contribution:node-bar:block:bar=" + bar + ",place=resources}" + + "own-middle\n${contribution:node-bar:block:bar=" + bar + ",place=status}own-last\n" + } + return Manifest{Module: "a-bar", Claims: []Claim{{Name: BarSeat, Renders: map[string]string{BarKindBlock: barTemplate}}}, + Resources: []map[string]any{ + {"id": "bottom", "type": "file", "path": "/b", "content": place("bottom")}, + {"id": "top", "type": "file", "path": "/t", "content": place("top")}, + }} +} + +func block(data string) SeatContribution { + var d map[string]any + if err := json.Unmarshal([]byte(data), &d); err != nil { + panic(err) + } + return SeatContribution{Seat: BarSeat, Kind: BarKindBlock, Data: d} +} + +func composedFile(t *testing.T, r Resolution, id string) string { + t.Helper() + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} + +func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"draw \"now\"","interval":5}}`), + }} + other := Manifest{Module: "another", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":70,"shows":"battery","options":{"device":"BAT1"}}`), + block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":60}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power, other}} + + // Ordered by order (20, 50, 70), then module order; each piece under its module's name; the + // command quoted so its own quotes cannot end the line. + want := "own-first\nown-middle\n" + + "# power\n[[block]]\nblock = \"custom\"\ncommand = \"draw \\\"now\\\"\"\ninterval = 5\n" + + "# power\n[[block]]\nblock = \"battery\"\n" + + "# another\n[[block]]\nblock = \"battery\"\ndevice = \"BAT1\"\n" + + "own-last\n" + if got := composedFile(t, r, "a-bar.bottom"); got != want { + t.Fatalf("the bottom bar is\n%s\nnot\n%s", got, want) + } + want = "own-first\n# another\n[[block]]\nblock = \"custom\"\ncommand = \"up\"\ninterval = 60\nown-middle\nown-last\n" + if got := composedFile(t, r, "a-bar.top"); got != want { + t.Fatalf("the top bar is\n%s\nnot\n%s", got, want) + } +} + +func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) { + c := block(`{"bar":"bottom","place":"status","shows":"battery"}`) + c.IfCapability = "battery" + power := Manifest{Module: "power", Contributions: []SeatContribution{c}} + for _, c := range []struct { + caps map[string]bool + has bool + }{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} { + r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), power}} + if bar := composedFile(t, r, "a-bar.bottom"); strings.Contains(bar, `block = "battery"`) != c.has { + t.Errorf("with %v: the battery placed is not %v:\n%s", c.caps, c.has, bar) + } + } +} + +func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) { + cases := map[string]string{ + // A name nothing detects would leave the piece out on every machine. + `{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports", + // A kind the holder depends on is never left out for want of something. + `{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers", + `{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + for _, name := range []string{"battery", "power-meter", "seat"} { + if !oneOf(KnownCapabilities, name) { + t.Errorf("%s is not a known capability", name) + } + } +} + +func TestResolveCarriesTheMachinesCapabilities(t *testing.T) { + node := workstation() + node.Capabilities["battery"] = true + r, err := Resolve(shelf(Manifest{Module: "a"}), []string{"a"}, node, World{}) + if err != nil { + t.Fatal(err) + } + if !r.Capabilities["battery"] || !r.Capabilities["seat"] { + t.Fatalf("the resolution carries %v, not the node's %v", r.Capabilities, node.Capabilities) + } +} + +func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) { + // A shows the shape gained after the holder's template was written: built here, past the check. + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"gpu"}`), + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + }} + r := Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), power}} + c, err := r.Compose(Rendering{}) + if err != nil { + t.Fatalf("one piece failed the whole machine: %v", err) + } + if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "power's node-bar:block") || + !strings.Contains(c.Unplaced[0], "shows=gpu") || !strings.Contains(c.Unplaced[0], "renders nothing") { + t.Fatalf("unplaced: %v", c.Unplaced) + } + for _, res := range c.Resources { + if res["id"] == "a-bar.bottom" && !strings.Contains(res["content"].(string), `block = "battery"`) { + t.Fatalf("the piece beside it was lost too: %s", res["content"]) + } + } +} + +// A moved bar is rendered by the provisioner's client, not by the controller, so the rule that its template renders +// every example of the shape is no longer judged here (issue 915, which also covers placementProblems not reading the +// collected form). What the controller still owns is that the bar's files place the blocks at all: a moved bar's +// carry the collected placement, and one that stopped placing them fails here rather than being skipped (issue 432). +func TestTheCataloguesBarPlacesBlocksAndItsShapeCoversEveryShow(t *testing.T) { + holder := catalogueManifest(t, "i3status-rust") + s, _ := SeatNamed(BarSeat) + if holder.Moved { + placed := "${collected:contribution:" + BarSeat + ":" + BarKindBlock + ":" + found := false + for _, res := range holder.Resources { + if content, _ := res["content"].(string); strings.Contains(content, placed) { + found = true + } + } + if !found { + t.Fatalf("the catalogue's moved bar places no blocks: none of its files carries %s…}", placed) + } + } else { + if !placesKind(holder, s, BarKindBlock) { + // It places them since the catalogue's bar took the seat; a skip here hid a bar that stopped + // (novox/hq issue 432). + t.Fatal("the catalogue's bar places no blocks, so none of the shape's examples would render") + } + tmpl, err := holderTemplate(holder, s, BarKindBlock) + if err != nil { + t.Fatal(err) + } + for _, ex := range s.Receives[0].Shape.Examples { + out, err := renderPiece(tmpl, "example", "a-machine", ex) + if err != nil || !strings.Contains(out, "[[block]]") { + t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) + } + } + } + for shows := range barShows { + covered := false + for _, ex := range s.Receives[0].Shape.Examples { + covered = covered || ex["shows"] == shows + } + if !covered { + t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows) + } + } +} + +func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) { + for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} { + q, err := quote(in) + if err != nil { + t.Fatal(err) + } + got, err := tomlBasicString(q) + if err != nil || got != in { + t.Errorf("quote(%q) = %s, which TOML reads as %q: %v", in, q, got, err) + } + } +} + +// tomlBasicString reads one TOML basic string, as the TOML specification (1.0, "String") defines it: +// any character but a quote, a backslash and the control characters U+0000–U+0008, U+000A–U+001F +// and U+007F, which are written as escapes. +func tomlBasicString(s string) (string, error) { + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return "", fmt.Errorf("not quoted") + } + var b strings.Builder + rs := []rune(s[1 : len(s)-1]) + for i := 0; i < len(rs); i++ { + r := rs[i] + switch { + case r == '"': + return "", fmt.Errorf("a bare quote at %d", i) + case r == 0x7f || (r < 0x20 && r != '\t'): + return "", fmt.Errorf("a bare control character %U at %d", r, i) + case r != '\\': + b.WriteRune(r) + continue + } + i++ + if i >= len(rs) { + return "", fmt.Errorf("an escape at the end") + } + switch rs[i] { + case 'b': + b.WriteRune('\b') + case 't': + b.WriteRune('\t') + case 'n': + b.WriteRune('\n') + case 'f': + b.WriteRune('\f') + case 'r': + b.WriteRune('\r') + case '"': + b.WriteRune('"') + case '\\': + b.WriteRune('\\') + case 'u', 'U': + n := 4 + if rs[i] == 'U' { + n = 8 + } + if i+n > len(rs)-1 { + return "", fmt.Errorf("a short \\u escape") + } + v, err := strconv.ParseUint(string(rs[i+1:i+1+n]), 16, 32) + if err != nil { + return "", err + } + b.WriteRune(rune(v)) + i += n + default: + return "", fmt.Errorf("the escape \\%c is not TOML's", rs[i]) + } + } + return b.String(), nil +} + +func TestABlockOutsideTheShapeIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", + `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", + `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", + `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", + `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + + `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a block in the shape was refused: %v", err) + } +} + +func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { + holder := func(template string, contents ...string) string { + var res []string + for i, c := range contents { + b, _ := json.Marshal(c) + res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) + } + tb, _ := json.Marshal(template) + renders := "" + if template != "" { + renders = `,"renders":{"block":` + string(tb) + `}` + } + return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` + } + all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + + "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" + cases := map[string]string{ + holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", + holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", + holder("", all): "gives no template", + holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", + holder(`{{.nothing}}`, all): "renders nothing", + holder(`{{if}`, all): "does not parse", + holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", + holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", + holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) + } + } + for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), + // A holder that places no block at all is older than the kind, and is not refused for it. + holder("", "no blocks here")} { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", raw, err) + } + } +} + +func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, + }} + for _, seat := range DependsOn(nil, power) { + if seat == BarSeat { + t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power)) + } + } + // And on a machine with no bar it is placed nowhere, and nothing fails. + r := Resolution{Node: "server", Account: "op", Capabilities: map[string]bool{"battery": true}, + Modules: []Manifest{hotkeysHolder(), power}} + if _, err := r.Declaration(Rendering{}); err != nil { + t.Fatal(err) + } +} + +func TestTheBarReceivesBlocksAsData(t *testing.T) { + s, _ := SeatNamed(BarSeat) + if len(s.Receives) != 1 || s.Receives[0].Kind != BarKindBlock || s.Receives[0].Shape == nil || !s.Receives[0].Offered { + t.Fatalf("node-bar receives %+v", s.Receives) + } + for _, ex := range s.Receives[0].Shape.Examples { + if p := barBlockProblems(ex); len(p) > 0 { + t.Errorf("the shape's own example %v is refused: %v", ex, p) + } + } +} + +func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *testing.T) { + power := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power}} + if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) { + t.Fatalf("the block does not read this machine's key:\n%s", bar) + } + if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}, "laptop"); len(got) != 1 || + got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) { + t.Fatalf("the holder is granted %v", got) + } + // Only the holder, and only for what the modules on its machine offer. + if got := ReadsGranted(power, []Manifest{barHolder(), power}, "laptop"); len(got) != 0 { + t.Fatalf("a contributor is granted %v", got) + } + if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 { + t.Fatalf("a holder alone is granted %v", got) + } +} + +func TestAModuleOffersItsOwnStateOnly(t *testing.T) { + block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}` + cases := map[string]string{ + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep", + `{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep", + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err) + } + } + ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("its own state was refused: %v", err) + } +} + +func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) + if err != nil || len(m.State) != 1 || !m.State[0].PerMachine { + t.Fatalf("%+v %v", m.State, err) + } + raw, _ := json.Marshal(StateDeclaration{Name: "draw", PerMachine: true}) + if string(raw) != `{"name":"draw","per-machine":true}` { + t.Fatalf("%s", raw) + } +} diff --git a/go/catalogue/seat_paths_test.go b/go/catalogue/seat_paths_test.go new file mode 100644 index 0000000..a6f5df5 --- /dev/null +++ b/go/catalogue/seat_paths_test.go @@ -0,0 +1,311 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0287 (issue 412): a seat names the paths its holder owns, a claim without them is +// refused, and only a claimant declares a seat's path. + +// The starting set, exactly: node-hostname names /etc/hosts and /etc/hostname, node-uplink the resolver +// file stepping back for node-resolver, node-resolver the resolver file, and every other seat names none. +func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) { + want := map[string]string{ + "node-hostname": "/etc/hosts /etc/hostname", + "node-uplink": "/etc/resolv.conf>node-resolver", + ResolverSeat: "/etc/resolv.conf", + } + for _, s := range DefaultSeats() { + var got []string + for _, p := range s.Paths { + if p.StepsBackFor != "" { + got = append(got, p.Path+">"+p.StepsBackFor) + } else { + got = append(got, p.Path) + } + } + if strings.Join(got, " ") != want[s.Name] { + t.Errorf("%s names %v; ADR 0287 gives it %q", s.Name, got, want[s.Name]) + } + } +} + +// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without +// stepping back, and a stepping back that cannot cycle. +func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) { + if p := SeatPathProblems(DefaultSeats()); len(p) != 0 { + t.Fatalf("the compiled set breaks ADR 0287: %v", p) + } + for _, c := range []struct { + why string + set []Seat + want string + }{ + {"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}}, + "only a node seat names paths"}, + {"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}}, + "placeholder"}, + {"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}}, + "not a clean absolute path"}, + {"two owners", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}, + {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"}, + {"a cycle", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}}, + {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}}, + "does not own that path itself"}, + {"stepping back for nobody", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}}, + "not a node seat"}, + } { + if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) { + t.Errorf("%s: %q does not say %q", c.why, got, c.want) + } + } +} + +// What the store's rows carry is never the paths: a row read back keeps the compiled ones. +func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) { + before := seats + t.Cleanup(func() { seats = before }) + UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}}) + s, _ := SeatNamed("node-hostname") + if len(s.Paths) != 2 { + t.Fatalf("node-hostname read back from its row names %v", s.Paths) + } +} + +func hostnameSeat(t *testing.T) Seat { + t.Helper() + s, ok := SeatNamed("node-hostname") + if !ok { + t.Fatal("no node-hostname seat") + } + return s +} + +func hostnameHolder(resources ...map[string]any) Manifest { + return Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, + Serves: []string{"entries", "add", "remove"}}}, Resources: resources} +} + +// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or +// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share. +func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) { + seat := hostnameSeat(t) + hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"} + name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"} + + if err := CanHold(hostnameHolder(hosts, name), seat); err != nil { + t.Errorf("a holder declaring both paths as resources was refused: %v", err) + } + rendered := hostnameHolder(hosts) + rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}} + if err := CanHold(rendered, seat); err != nil { + t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err) + } + err := CanHold(hostnameHolder(), seat) + if err == nil { + t.Fatal("a holder declaring neither path was accepted") + } + for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + // A file under the operator's home is placed per account: it is not the machine's path. + home := hostnameHolder(hosts) + home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}} + if err := CanHold(home, seat); err == nil { + t.Error("a file in the operator's home counted as the machine's /etc/hostname") + } + // At registration, the same refusal. + if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got, + "does not declare /etc/hostname") { + t.Errorf("registration let a holder without /etc/hostname through: %q", got) + } +} + +// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring +// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring +// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed. +func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) { + stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} + got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") + if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) + } + // However the path is spelled (novox/hq issue 412 review). + unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}} + if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") { + t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got) + } + reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}} + if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 { + t.Errorf("reading /etc/hosts was refused: %v", got) + } + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + resolver := localResolver() + resolver.Claims[0].Serves = []string{"routes", "route", "unroute"} + if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 { + t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got) + } +} + +// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat +// names — the second declaration loses. Its holder declares them, as a mesh seat's does. +func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) { + meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Paths: []string{"/etc/a"}}}} + if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") { + t.Errorf("a module's mesh seat naming a path was accepted: %q", got) + } + placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}} + if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") { + t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got) + } + + taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}} + if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got, + `b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) { + t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got) + } + + own := Manifest{Module: "c", Version: "1", + DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}}, + Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}} + if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got, + "c claims c-seat but does not declare /etc/c.conf") { + t.Errorf("a holder of a module's seat without its path was accepted: %q", got) + } + own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}} + if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 { + t.Errorf("a holder declaring its own seat's path was refused: %v", got) + } + other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}} + if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got, + `d declares the path "/etc/c.conf", which the seat c-seat names`) { + t.Errorf("a module not claiming c-seat declared its path: %q", got) + } +} + +type writesHosts struct{} + +func (writesHosts) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", + "content": "10.42.0.1 anchor\n"}}, true, nil +} + +// The controller is bound too: what it generates for a machine on a seat's path is refused at composition. +func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-names", Computed: "mesh-names"}, + }} + _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-names": writesHosts{}}}) + if err == nil { + t.Fatal("a generated resource on /etc/hosts was composed") + } + for _, want := range []string{"mesh-names", "/etc/hosts", "node-hostname"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back +// applied — the resolver's holder where node-resolver is held, else the uplink's. +func TestPathOwnersApplyTheSteppingBack(t *testing.T) { + held := []Held{ + {Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"}, + {Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"}, + {Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"}, + {Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"}, + } + var got []string + for _, o := range PathOwners(Seats(), held) { + got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module) + } + want := []string{ + "/etc/hostname server node-hostname hostname", + "/etc/hosts server node-hostname hostname", + "/etc/resolv.conf laptop node-resolver systemd-resolved", + "/etc/resolv.conf server node-uplink systemd-networkd", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// The stepping back comes from the seat, and is of a rendered file only: an uplink holder declaring the +// resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the +// machine is resolved — never composed twice — as before the seat named its paths. +func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t *testing.T) { + shelf := resolverShelf(t) + uplink := shelf["systemd-networkd"] + uplink.Facts = nil + uplink.Resources = append(append([]map[string]any(nil), uplink.Resources...), + map[string]any{"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"}) + shelf["systemd-networkd"] = uplink + shelf["local-resolver"] = localResolver() + _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) + if err == nil || !strings.Contains(err.Error(), `both declare the path "/etc/resolv.conf"`) { + t.Fatalf("an uplink declaring the resolver file as a resource beside the resolver was not refused: %v", err) + } + // Its rendered file steps back, by the seat's definition, and nothing else of it does. + rendered := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, + Facts: map[string]RosterFile{"resolvers": {Path: "/etc//resolv.conf", Template: "x"}, + "other": {Path: "/etc/uplink.conf", Template: "x"}}} + got := stepsBack(rendered, []Manifest{rendered, localResolver()}) + if len(got.Facts) != 1 || got.Facts["other"].Path == "" { + t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts) + } +} + +// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts. +func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { + for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} { + stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}} + if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got, + `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got) + } + holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"}, + map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}) + if err := CanHold(holder, hostnameSeat(t)); err != nil { + t.Errorf("a holder declaring %s was refused: %v", spelled, err) + } + } +} + +// A seat names a role's own path, never the root or a top-level directory. +func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) { + for _, p := range []string{"/", "/etc"} { + m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") { + t.Errorf("a module's seat naming %s was accepted: %q", p, got) + } + } +} + +// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else +// declares one. +func TestTheCataloguePassesTheSeatPathRules(t *testing.T) { + shelf := Shelf{} + for _, m := range theCatalogue(t) { + shelf[m.Module] = m + } + for _, p := range CatalogueProblems(shelf) { + if strings.Contains(p, "ADR 0287") { + t.Errorf("the catalogue breaks ADR 0287: %s", p) + } + } +} diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go new file mode 100644 index 0000000..beceaab --- /dev/null +++ b/go/catalogue/seats_test.go @@ -0,0 +1,340 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set. + +// The set is closed, and changing it is a decision. +// +// **The count is asserted, and every entry names the record that made it a seat**, so the next +// person changing the set finds the argument rather than a number to edit — the pattern the host's +// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq +// and a row in to-be 26, not a new number here. +func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { + // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined + // it and the one that seeded it. + record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`) + seen := map[string]bool{} + delivered := map[string]string{} + for _, s := range Seats() { + if seen[s.Name] { + t.Errorf("%s is in the set twice", s.Name) + } + seen[s.Name] = true + if !record.MatchString(s.Decision) { + t.Errorf("%s names %q as its decision; every seat names the record that made it one", + s.Name, s.Decision) + } + switch s.Scope { + case ScopeNode, ScopeSite, ScopeMesh: + default: + t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope) + } + if s.Delivers != "" { + // Two seats answering for one provision would put the question "which one?" back, + // which is the question a seat exists to answer. + if other, twice := delivered[s.Delivers]; twice { + t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers) + } + delivered[s.Delivers] = s.Name + } + } + // Forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes. + // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // into node-uplink (novox/hq ADR 0223); thirty-seven + // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with + // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); + // thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now + // node-hostname); thirty-four + // with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the + // graphical session's eleven (ADR 0208); twenty-one with node-package-manager and + // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, + // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired + // mesh-build-machine row goes, when no registered manifest claims it. + if len(Seats()) != 45 { + t.Errorf("the mesh defines %d seats rather than 45; the set is closed, so a change here is "+ + "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) + } +} + +// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing. +// +// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from +// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer +// for something, and the manager's link is the one thing the mesh must never be able to break. +func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { + seat, known := SeatNamed("node-uplink") + if !known { + t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) + } + if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" { + t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) + } + // And a manager's module can hold it without providing anything. + raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`) + if _, err := ParseManifest(raw); err != nil { + t.Fatalf("a network manager's module could not hold the uplink: %v", err) + } +} + +func claimed(claims string) []byte { + return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`) +} + +// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now +// declare its own seats, so whether a claimed seat exists is a fact about the *catalogue* and not +// about the manifest in front of the parser: a claim on a seat another registered module declares +// is perfectly good, and the parser cannot tell the two cases apart. So the parser accepts it and +// registration refuses it — the same guarantee, at the same moment work would otherwise start, +// from a set nobody maintains by hand. +func TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration(t *testing.T) { + m, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) + } + + problems := CatalogueProblems(Shelf{m.Module: m}) + if len(problems) == 0 { + t.Fatal("a module invented a seat by claiming it, and registration allowed it") + } + joined := strings.Join(problems, "; ") + if !strings.Contains(joined, "the-anything") || !strings.Contains(joined, "no module declares") { + t.Fatalf("the refusal does not say the seat is nobody's: %v", problems) + } +} + +// And the same claim is fine once something declares that seat, which is the case the parser +// could not have distinguished. +func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) { + claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatal(err) + } + declarer := Manifest{Module: "someone", DefinesSeats: []SeatDeclaration{ + {Name: "the-anything", Scope: ScopeNode, Accepts: []string{"work"}}, + }} + if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 { + t.Fatalf("a claim on a declared seat was refused: %v", problems) + } +} + +// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing +// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace. +func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { + ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + + `"claims":[{"name":"the-showcase","scope":"node"}]}`) + if _, err := ParseManifest(ok); err != nil { + t.Fatalf("a module could not define and claim its own seat: %v", err) + } + // Claiming a name nobody defines is still refused — but **at registration, not here**: with + // seats declared by modules, a claim on a seat *another* module declares is good, and the + // parser cannot tell that from an invented name. See + // TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration. + claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) + } + if len(CatalogueProblems(Shelf{claimant.Module: claimant})) == 0 { + t.Fatal("a module claimed a seat nobody defines") + } + // A module may not carve its seat out of the mesh's own namespace. + bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` + + `"claims":[{"name":"node-mine","scope":"node"}]}`) + if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") { + t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err) + } +} + +// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the +// set, the set is the store's, and the parser also runs on a build machine that has no store. +func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { + m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err) + } + got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") + if !strings.Contains(got, "mesh seat") { + t.Fatalf("the refusal does not say which scope the seat is: %q", got) + } +} + +func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { + // Holding it makes the module the mesh's answer for the provision. A module that cannot answer + // would be the answer anyway, and every consumer would be sent to it. + // And refused at registration, where the seat set is the store's: what a seat delivers is + // data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122). + raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the parser judged what a seat delivers: %v", err) + } + got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") + if !strings.Contains(got, `does not provide "git"`) { + t.Fatalf("the refusal does not say what is missing: %q", got) + } +} + +func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) { + // Not a second time for being unknown: one mistake, one line. + _, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`)) + if err == nil { + t.Fatal("a malformed claim was accepted") + } + if strings.Contains(err.Error(), "not a seat") { + t.Fatalf("a malformed claim was also called unknown: %v", err) + } +} + +// Every module in use claims a seat in the set, so closing it refuses nothing that runs. +// +// Read from the catalogue (internal/beside) and from this repository's own manifest, the two places a +// manifest lives (ADR 0069). The private-network module's manifest is composed in code, and its claim +// is checked where it is composed. +func TestEveryManifestInUseClaimsASeatTheMeshDefines(t *testing.T) { + paths, _ := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) + if len(paths) == 0 { + t.Fatal("no manifests in the catalogue, so this proved nothing") + } + paths = append(paths, "../../module.json") + var checked int + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Leniently, so a manifest refused for something unrelated is not reported as a seat + // problem, and the seat check below is the only thing this test holds a module to. + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", path, err) + } + for _, problem := range claimProblems(m) { + t.Errorf("%s: %s", path, problem) + } + checked += len(m.Claims) + } + if checked == 0 { + t.Fatal("no claims were checked, so this proved nothing") + } +} + +// The holder of a seat answers among several providers. + +func registryShelf() map[string]Manifest { + return shelf( + Manifest{Module: "gitea", Version: "1", Provides: FromAnywhere("npm-package-registry"), + Claims: []Claim{{Name: "npm-package-registry", Scope: ScopeMesh}}}, + Manifest{Module: "verdaccio", Version: "1", Provides: FromAnywhere("npm-package-registry")}, + Manifest{Module: "builder", Version: "1", Requires: []string{"npm-package-registry"}}, + ) +} + +func twoRegistries() map[string][]Provider { + return map[string][]Provider{"npm-package-registry": { + {Node: "anchor", At: "anchor.internal", Module: "gitea"}, + {Node: "archive", At: "archive.internal", Module: "verdaccio"}, + }} +} + +func giteaHoldsTheSeat() []Held { + return []Held{{Claim: "npm-package-registry", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} +} + +func TestTheSeatsHolderAnswersWhenSeveralProvide(t *testing.T) { + // The whole point: a second registry beside the holder harms nothing, and nobody pins. + got, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries(), Held: giteaHoldsTheSeat()}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "anchor" { + t.Fatalf("the seat's holder did not answer: %v", got.Needs) + } +} + +func TestAPinStillWinsOverTheSeat(t *testing.T) { + // A consumer coupled to one provider's contents has said so, and the seat does not overrule it. + got, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(), + Pinned: map[string]Chosen{"npm-package-registry": {Node: "archive", Module: "verdaccio"}}}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the pin was overruled by the seat: %v", got.Needs) + } +} + +func TestWithTheSeatUnheldSeveralProvidersAreStillRefused(t *testing.T) { + // No seat held is no choice made, and ADR 0009's rule stands: never guessed. + _, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries()}) + if err == nil { + t.Fatal("one of two registries was picked with nobody holding the seat") + } + if !strings.Contains(err.Error(), "pin") { + t.Fatalf("the refusal does not say how to choose: %v", err) + } +} + +func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { + // Two modules on one machine could provide the same thing; only the one holding the seat + // answers. A holder matched by node alone would send consumers to whichever came first. + providers := []Provider{ + {Node: "anchor", At: "anchor.internal", Module: "verdaccio"}, + {Node: "anchor", At: "anchor.internal", Module: "gitea"}, + } + holder, held := HolderAmong("npm-package-registry", providers, giteaHoldsTheSeat()) + if !held || holder.Module != "gitea" { + t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) + } +} + +// The working set is loaded from the store, and an empty load never erases it (novox/hq ADR 0122). +func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { + before := Seats() + defer UseSeats(DefaultSeats()) // restore for other tests, whatever this leaves it as + + // An empty load (store not seeded, or unreadable) leaves the compiled defaults in force. + UseSeats(nil) + if len(Seats()) != len(before) { + t.Fatalf("an empty load changed the set from %d to %d seats", len(before), len(Seats())) + } + // A non-empty load replaces it — this is how a rename in the store reaches the lookups. + UseSeats([]Seat{{Name: "node-firewall", Scope: ScopeNode, Decision: "novox/hq ADR 0122"}}) + if _, known := SeatNamed("node-firewall"); !known { + t.Fatal("the loaded set did not replace the working set") + } + if len(Seats()) != 1 { + t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) + } +} + +// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim +// and a held record naming the old name break nothing after a rename. +func TestAFormerNameResolvesAfterARename(t *testing.T) { + defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }() + UseSeats([]Seat{{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}}) + UseAliases(map[string]string{"git": "git"}) + + // The old name resolves to the renamed seat. + if s, ok := SeatNamed("git"); !ok || s.Name != "git" { + t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok) + } + // And a holder recorded under the old name is still found for the provision the seat delivers. + providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}} + held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} + holder, found := HolderAmong("git", providers, held) + if !found || holder.Module != "gitea" { + t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found) + } +} diff --git a/go/catalogue/showcase_manifest_test.go b/go/catalogue/showcase_manifest_test.go new file mode 100644 index 0000000..d6ecf33 --- /dev/null +++ b/go/catalogue/showcase_manifest_test.go @@ -0,0 +1,86 @@ +package catalogue + +import ( + "os" + "path/filepath" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// **The showcase module is parsed by the real parser, in the real test suite.** +// +// A module that exercises every capability is only worth having if something checks it still does. +// Written as a test rather than a script so it runs whenever anything about manifests changes — +// which is exactly when a module using all of it would quietly stop being valid. +func TestTheShowcaseModuleIsAValidManifest(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "showcase", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the module that exercises everything does not parse:\n%v", err) + } + + // Every resource kind a MODULE may use, actually in it. + // + // Two of the host's eleven are deliberately absent, and the reasons are worth keeping: + // + // - `action` is refused to modules outright. The link may not carry a command to run (ADR + // 0005), so a module that needs something done ships a program that reads what the mesh + // delivered and reconciles — which is what a run-once `process` is. + // - `service` puts an EXISTING unit into a state and deliberately installs none, which is + // right for software that ships its own unit. A module whose code the mesh built has no + // such unit until the mesh writes one, and that is a `process`. + kinds := map[string]bool{} + for _, r := range m.Resources { + kind, _ := r["type"].(string) + kinds[kind] = true + } + for _, want := range []string{ + "access", "archive", "container", "directory", "file", "network", "package", + "process", "user", + } { + if !kinds[want] { + t.Errorf("showcase no longer exercises %q", want) + } + } + + // And all three ways a module's own code can run, which is the thing most easily lost. + var stays, once, scheduled bool + for _, r := range m.Resources { + if kind, _ := r["type"].(string); kind != "process" { + continue + } + switch { + case r["run-once"] == true: + once = true + case r["schedule"] != nil: + scheduled = true + default: + stays = true + } + } + if !stays || !once || !scheduled { + t.Errorf("showcase does not exercise all three process modes: stays=%v once=%v scheduled=%v", + stays, once, scheduled) + } + + // And the artifact kinds, including the one that compiles. + var bundle, archive, upstream bool + for _, a := range m.Build.Artifacts { + switch a.Kind { + case ArtifactBundle: + bundle = true + case ArtifactArchive: + archive = true + case ArtifactUpstream: + upstream = true + } + } + if !bundle || !archive || !upstream { + t.Errorf("showcase does not exercise every artifact kind: bundle=%v archive=%v upstream=%v", + bundle, archive, upstream) + } +} diff --git a/go/catalogue/ssh_client_test.go b/go/catalogue/ssh_client_test.go new file mode 100644 index 0000000..8981e31 --- /dev/null +++ b/go/catalogue/ssh_client_test.go @@ -0,0 +1,117 @@ +package catalogue + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" +) + +// boundKeys are every key the files of a module ask of ${bound:forge-account:}, read from the module's own +// tree on the shelf, so the test offers what ssh-client asks however often it asks one more (issue 985). +func boundKeys(t *testing.T, module, provision string) map[string]bool { + t.Helper() + ask := regexp.MustCompile(`\$\{bound:` + regexp.QuoteMeta(provision) + `:([A-Za-z0-9_.-]+)\}`) + keys := map[string]bool{} + root := filepath.Join(beside.Catalogue(t), module) + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.HasSuffix(path, "_test.go") { + return err + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + for _, m := range ask.FindAllStringSubmatch(string(body), -1) { + keys[m[1]] = true + } + return nil + }) + if err != nil { + t.Fatalf("reading what %s asks of %s: %v", module, provision, err) + } + return keys +} + +// The ssh-client module, composed as a machine receives it (novox/hq to-be 29, research 027/03): a +// region at the START of the operator's ~/.ssh/config that includes ~/.ssh/config.d/* — first, +// because ssh takes the first value it finds for each option — and the mesh's Host blocks as the +// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d +// are created 0700 and owned by the account; the operator's own config below the region is kept. +func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) { + shelf := shelf(catalogueManifest(t, "ssh-client"), catalogueManifest(t, "gitea")) + // ssh-client requires forge-account since mesh-catalog #340 (novox/hq issue 955), answered by the forge's + // holder on another node; this test is about the operator's config, so the forge is offered from there + // (issue 985). + // Every key ssh-client's files ask is offered, read from them (boundKeys); a few have a realistic value. + serves := map[string]any{} + for key := range boundKeys(t, "ssh-client", "forge-account") { + serves[key] = "sample-" + key + } + for key, value := range map[string]any{"ssh_host": "marge.internal", "ssh_port": 222, + "known_host": "[marge.internal]:222 ssh-ed25519 AAAA", "ssh_base": "ssh://git@marge.internal:222/", + "https_base": "https://git.example.org/"} { + serves[key] = value + } + forge := World{Offered: map[string][]Provider{ + "forge-account": {{Node: "marge", At: "marge.internal", Module: "gitea", Serves: serves}}}} + got, err := Resolve(shelf, []string{"ssh-client"}, + Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, forge) + if err != nil { + t.Fatal(err) + } + names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2", + "bart.internal": "10.10.0.3"} + out, err := got.Declaration(Rendering{ + Names: names, Machines: names, + Accounts: map[string]string{"homer": "jo", "marge": "jo", "bart": "op"}, + Suffix: "internal", + }) + if err != nil { + t.Fatal(err) + } + by := map[string]map[string]any{} + for _, r := range out { + by[r["id"].(string)] = r + } + + for id, path := range map[string]string{ + "ssh-client.ssh-dir": "/home/jo/.ssh", "ssh-client.config-d": "/home/jo/.ssh/config.d"} { + dir := by[id] + if dir == nil || dir["type"] != "directory" || dir["path"] != path || dir["owner"] != "jo" || dir["mode"] != "0700" { + t.Fatalf("%s is not created 0700 owned by the account: %v", path, dir) + } + } + + cfg := by["ssh-client.config"] + if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" || + cfg["into"] != "block" || cfg["at"] != "start" { + t.Fatalf("the mesh's region is not the first thing in the operator's ~/.ssh/config: %v", cfg) + } + if body := cfg["content"].(string); !strings.Contains(body, "\nInclude ~/.ssh/config.d/*\n") || strings.Contains(body, "\nHost ") { + t.Fatalf("the region does not just include config.d:\n%s", body) + } + + var hosts map[string]any + for _, r := range out { + if r["path"] == "/home/jo/.ssh/config.d/00-mesh" { + hosts = r + } + } + if hosts == nil || hosts["type"] != "file" || hosts["into"] != nil { + t.Fatalf("the mesh's hosts are not the whole of ~/.ssh/config.d/00-mesh: %v", hosts) + } + body := hosts["content"].(string) + for _, want := range []string{"Host marge marge.internal", "Host bart bart.internal", "User jo", "User op"} { + if !strings.Contains(body, want) { + t.Fatalf("00-mesh does not say %q — every other node with its account:\n%s", want, body) + } + } + if strings.Contains(body, "Host homer ") { + t.Fatalf("00-mesh names the machine itself, not only its peers:\n%s", body) + } +} diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go new file mode 100644 index 0000000..d4cf687 --- /dev/null +++ b/go/catalogue/state_test.go @@ -0,0 +1,125 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its +// bare name or with the owner's options. +func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` + + `"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` + + `"reads":["licence-manager.bindings"]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 { + t.Fatalf("state not read: %+v", m.State) + } + if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" { + t.Fatalf("reads not read: %v", m.Reads) + } + // Written back as it came in: the short form where nothing else is said. + out, _ := json.Marshal(m.State) + if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` { + t.Fatalf("written back as %s", out) + } +} + +// A name the bus could not hold, or that would let two modules derive one bucket, is refused at +// registration in the manifest's words. +func TestAStateNameIsLocalAndOneToken(t *testing.T) { + for _, c := range []struct{ manifest, says string }{ + {`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`}, + {`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`}, + {`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`}, + {`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`}, + {`{"module":"a.b","version":"1","state":["s"]}`, `no dot`}, + {`{"module":"a","version":"1","reads":["bindings"]}`, `a read is .`}, + {`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`}, + {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl}`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +// A read whose owner is present must name a state that owner keeps; an absent owner says nothing, +// because a module may be installed before the one whose state it reads. +func TestAReadNamesStateItsOwnerKeeps(t *testing.T) { + owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}} + good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}} + bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}} + if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 { + t.Fatalf("a read of declared state was refused: %v", p) + } + p := StateReadsNothingDeclares([]Manifest{owner, bad}) + if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) { + t.Fatalf("a read of state nobody keeps was not named: %v", p) + } +} + +// **The controller's record mirrors are kept state** (design 54 §5, design 57 F3): a module may read +// `mesh-controller.assignments` and `mesh-controller.declared-config` although the controller's manifest +// declares no `state`; a read of anything else of the controller's is still named. +func TestAReadOfTheControllersMirrorsIsAReadOfKeptState(t *testing.T) { + controller := Manifest{Module: ControllerSeatName} + reader := Manifest{Module: "mesh-config", Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config"}} + if p := StateReadsNothingDeclares([]Manifest{controller, reader}); len(p) != 0 { + t.Fatalf("a read of the controller's mirrors was refused: %v", p) + } + other := Manifest{Module: "other", Reads: []string{"mesh-controller.calls"}} + if p := StateReadsNothingDeclares([]Manifest{controller, other}); len(p) != 1 { + t.Fatalf("a read of the controller's own calls was not named: %v", p) + } +} + +// **Across the whole catalogue**: every state name is local, and every read whose owner is present +// names state that owner keeps. +func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) { + manifests := theCatalogue(t) + var problems []string + for _, m := range manifests { + problems = append(problems, StateProblems(m)...) + } + problems = append(problems, StateReadsNothingDeclares(manifests)...) + if len(problems) > 0 { + t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n ")) + } +} + +// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps. +func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) { + shelf := Shelf{ + "licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}, + "claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}}, + } + problems := CatalogueProblems(shelf) + if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) { + t.Fatalf("the catalogue pass said %v", problems) + } +} + +// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before. +func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + + `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`)) + if err != nil { + t.Fatal(err) + } + if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL { + t.Fatalf("key-ttl not read as said: %+v", m.State) + } + out, _ := json.Marshal(m.State) + if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` { + t.Fatalf("written back as %s", out) + } +} diff --git a/go/catalogue/two_resolvers_test.go b/go/catalogue/two_resolvers_test.go new file mode 100644 index 0000000..f68bb95 --- /dev/null +++ b/go/catalogue/two_resolvers_test.go @@ -0,0 +1,271 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The mesh has two resolvers (novox/hq ADR 0223): `mesh-dns-resolver` is replicated, held on the +// anchor and on the home server, each answering the same names; every machine's resolver file lists +// every holder — its own first when it is one — and no public resolver. ADR 0196 listed the mesh's +// resolver then a public one, and musl asks both at once and takes the first reply: from the home +// server the public "no such name" for the anchor's mesh name won, every time, in every Alpine build. +// The file is written by the module holding the machine's uplink (ADR 0223 part 2). + +// resolverMachines is the anchor and the home server holding the resolver, and a laptop holding nothing. +var resolverMachines = map[string]string{ + "anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "home.internal": "10.42.0.3"} + +// bothResolvers is the two holders on record, as `seat mesh-dns-resolver --add` leaves them. +var bothResolvers = []Held{ + {Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "dnsmasq"}, + {Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "home", Module: "dnsmasq"}, +} + +// uplinks is every module in the catalogue holding node-uplink, and so writing the machine's resolver +// file (novox/hq ADR 0223 part 2): the program that would otherwise rewrite it is the one that writes it. +// dhcpcd's left the catalogue with ADR 0226, held by no machine. +var uplinks = []string{"networkmanager", "systemd-networkd"} + +// managing is a machine as each uplink module needs it: able to install, run a service and run the +// manager that module is for. +func managing(node string) Node { + caps := map[string]bool{"package-manager": true, "service-manager": true} + for _, u := range uplinks { + caps["uplink-"+u] = true + } + return Node{Name: node, At: node + ".internal", Capabilities: caps} +} + +// twoResolverShelf is the resolver, the uplink modules that write what a machine asks, and a stand-in +// answering `mesh-addressing`. +func twoResolverShelf(t *testing.T) map[string]Manifest { + t.Helper() + out := map[string]Manifest{ + "net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}}, + "dnsmasq": catalogueManifest(t, "dnsmasq"), + } + for _, u := range uplinks { + out[u] = catalogueManifest(t, u) + } + return out +} + +// worldWithout is the rest of the mesh as a plan for one machine sees it: every other holder's claim +// and offer, and both holders on record. +func worldWithout(node string) World { + w := World{Holdings: bothResolvers, Offered: map[string][]Provider{}} + for _, h := range bothResolvers { + if h.Node == node { + continue + } + w.Held = append(w.Held, h) + w.Offered["wildcard-resolution"] = append(w.Offered["wildcard-resolution"], + Provider{Node: h.Node, At: h.Node + ".internal", Module: h.Module}) + } + return w +} + +// resolvConfOn resolves and composes one machine and answers with the nameservers its resolver file +// lists, in order, and the file. The file is the uplink's — `uplink` is one of the assigned modules — +// and nothing else on the machine declares that path. +func resolvConfOn(t *testing.T, node, uplink string, assigned []string) ([]string, string) { + t.Helper() + got, err := Resolve(twoResolverShelf(t), assigned, managing(node), worldWithout(node)) + if err != nil { + t.Fatalf("%s with %s does not resolve with two resolvers on record: %v", node, uplink, err) + } + out, err := got.Declaration(Rendering{ + Names: resolverMachines, Machines: resolverMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": { + "anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + }) + if err != nil { + t.Fatalf("%s with %s does not compose: %v", node, uplink, err) + } + var file map[string]any + for _, r := range out { + if r["path"] != "/etc/resolv.conf" { + continue + } + if file != nil { + t.Fatalf("%s declares /etc/resolv.conf twice: %v and %v", node, file["id"], r["id"]) + } + file = r + } + if file == nil || file["id"] != uplink+".fact-resolvers" { + t.Fatalf("%s's resolver file is not %s's: %v", node, uplink, file) + } + content, _ := file["content"].(string) + var servers []string + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "nameserver ") { + servers = append(servers, strings.TrimPrefix(line, "nameserver ")) + } + } + return servers, content +} + +// Per uplink module: each writes a resolver file listing both holders, the machine's own first on a +// holder, and only the holders on a machine that is none. +func TestEveryUplinkListsBothResolversItsOwnFirst(t *testing.T) { + for _, uplink := range uplinks { + for node, want := range map[string][]string{ + "anchor": {"10.42.0.1", "10.42.0.3"}, + "home": {"10.42.0.3", "10.42.0.1"}, + "laptop": {"10.42.0.1", "10.42.0.3"}, + } { + assigned := []string{uplink} + if node != "laptop" { + assigned = append(assigned, "dnsmasq") + } + servers, content := resolvConfOn(t, node, uplink, assigned) + if strings.Join(servers, " ") != strings.Join(want, " ") { + t.Errorf("%s on %s lists %v; want %v\n%s", uplink, node, servers, want, content) + } + for _, public := range []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"} { + if strings.Contains(content, public) { + t.Errorf("%s on %s lists a public resolver beside the mesh's (ADR 0223):\n%s", uplink, node, content) + } + } + if !strings.HasSuffix(content, "\noptions timeout:1 attempts:2 edns0\n") { + t.Errorf("%s on %s does not end with two short attempts:\n%s", uplink, node, content) + } + } + } +} + +// One file, whichever manager the machine runs: the three modules carry the same template, so a +// machine changing its manager changes nothing in what it asks, and a fix made to one is made to all. +func TestEveryUplinkWritesTheSameResolverFile(t *testing.T) { + var first, firstOf string + for _, uplink := range uplinks { + fact, ok := catalogueManifest(t, uplink).Facts["resolvers"] + if !ok || fact.Path != "/etc/resolv.conf" || fact.Shared || fact.Home { + t.Fatalf("%s does not write the machine's resolver file whole: %+v", uplink, fact) + } + if first == "" { + first, firstOf = fact.Template, uplink + continue + } + if fact.Template != first { + t.Errorf("%s's resolver file differs from %s's; the three are kept identical", uplink, firstOf) + } + } +} + +// The requirement stays with what writes the file (ADR 0223 part 1): a machine is refused when nothing +// in the mesh resolves, rather than given a file listing nothing. +func TestEveryUplinkRequiresTheMeshsResolver(t *testing.T) { + for _, uplink := range uplinks { + found := false + for _, r := range catalogueManifest(t, uplink).Requires { + found = found || r == "wildcard-resolution" + } + if !found { + t.Errorf("%s writes the resolver file and does not require wildcard-resolution", uplink) + } + } + _, err := Resolve(twoResolverShelf(t), []string{"networkmanager"}, managing("laptop"), World{}) + if err == nil || !strings.Contains(err.Error(), "wildcard-resolution") { + t.Errorf("an uplink was composed on a mesh with no resolver: %v", err) + } +} + +// A holder answers its own requirement, even though the other holder sorts first (issue 258 kept). +func TestAHolderAnswersItsOwnRequirement(t *testing.T) { + got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq", "networkmanager"}, + managing("home"), worldWithout("home")) + if err != nil { + t.Fatal(err) + } + for _, n := range got.Needs { + if n.Name == "wildcard-resolution" && n.From != "home" { + t.Errorf("the home server's uplink is bound to %s; it holds the seat itself", n.From) + } + } +} + +// A seat held once is still held once: a second claimant on another machine is refused while +// nothing is on record, and a store recording two holders of it is refused, naming the seat. +func TestASingleHolderMeshSeatStillRefusesASecondHolder(t *testing.T) { + store := shelf(mod("postgres", nil, nil, nil, Claim{Name: "mesh-store", Scope: ScopeMesh})) + other := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "postgres"} + if _, err := Resolve(store, []string{"postgres"}, workstation(), World{Held: []Held{other}}); err == nil || + !strings.Contains(err.Error(), "one per mesh") { + t.Errorf("a second claimant of a seat held once was not refused: %v", err) + } + here := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: workstation().Name, Module: "postgres"} + _, err := Resolve(store, []string{"postgres"}, workstation(), + World{Held: []Held{other}, Holdings: []Held{other, here}}) + if err == nil || !strings.Contains(err.Error(), "on record as held by 2") { + t.Errorf("two holders on record for a seat held once were not refused: %v", err) + } +} + +// Replicated is not "whoever is assigned": two claimants with nothing on record are refused, as for +// any mesh seat, and each holder is added by an act. +func TestTwoUnrecordedClaimantsOfTheReplicatedSeatAreRefused(t *testing.T) { + w := worldWithout("home") + w.Holdings = nil + _, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "home", At: "home.internal"}, w) + if err == nil || !strings.Contains(err.Error(), "seat mesh-dns-resolver --to") { + t.Errorf("a second resolver with nothing on record was not refused, naming the handover: %v", err) + } +} + +// Only the mesh's resolver is replicated: a seat being replicated is a decision, recorded. +func TestOnlyTheResolverIsReplicated(t *testing.T) { + for _, s := range Seats() { + if s.Replicated != (s.Name == "mesh-dns-resolver") { + t.Errorf("%s replicated = %v; only mesh-dns-resolver is (ADR 0223)", s.Name, s.Replicated) + } + } + UseSeats([]Seat{{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution"}}) + defer UseSeats(DefaultSeats()) + if s, _ := SeatNamed("mesh-dns-resolver"); !s.Replicated { + t.Error("loading the set from the store, which has no column for it, lost the resolver's replication") + } +} + +// Every consumer is bound to the same holder whatever order the mesh resolved its machines in. +func TestTheFirstHolderIsTheFirstProvider(t *testing.T) { + providers := []Provider{{Node: "anchor", Module: "dnsmasq"}, {Node: "home", Module: "dnsmasq"}} + for _, held := range [][]Held{bothResolvers, {bothResolvers[1], bothResolvers[0]}} { + if p, ok := HolderAmong("wildcard-resolution", providers, held); !ok || p.Node != "anchor" { + t.Errorf("held in order %v answered %v", held, p) + } + } +} + +// One host record per machine, beside its wildcard (novox/hq issue 262): a name with a host record +// says it exists and has no IPv6 address, where the wildcard alone said there is no such name, and +// musl reads that as final. +func TestTheResolverHasOneHostRecordPerMachine(t *testing.T) { + got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor", At: "anchor.internal"}, + World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: resolverMachines, Machines: resolverMachines, Suffix: "internal", + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}}) + if err != nil { + t.Fatal(err) + } + content, _ := byID(out)["dnsmasq.fact-node-zones"]["content"].(string) + records := map[string]int{} + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "host-record=") { + records[strings.TrimPrefix(line, "host-record=")]++ + } + } + for name, at := range resolverMachines { + if records[name+","+at] != 1 { + t.Errorf("%s has %d host records at %s, and has one:\n%s", name, records[name+","+at], at, content) + } + } + if len(records) != len(resolverMachines) { + t.Errorf("%d host records for %d machines:\n%s", len(records), len(resolverMachines), content) + } +} diff --git a/go/internal/beside/beside.go b/go/internal/beside/beside.go new file mode 100644 index 0000000..b64f67c --- /dev/null +++ b/go/internal/beside/beside.go @@ -0,0 +1,95 @@ +// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests, +// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449). +// +// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on +// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none +// was beside it skipped and said nothing. Now there are two inputs, both named, and no third: +// +// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue +// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A +// test judges against those clones, so agreement with the other repository is checked where +// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host +// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's +// go.mod pins. In a mesh where either module has no source repository nothing is +// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the +// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the +// catalogue's main, not the group's head. +// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the +// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set +// MESH_CHECK_BESIDE to the directory holding it, as the check does. +// +// The captured manifests are named module.json.captured, and put back as module.json in a directory of +// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and +// the planner, and a merge would build and register a hundred copies of the catalogue's. +package beside + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's +// EnvBeside, repeated here so a test does not import the builder). +const Env = "MESH_CHECK_BESIDE" + +// CapturedSuffix is what a captured file's name carries that the repository's own does not. +const CapturedSuffix = ".captured" + +// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and +// says which in the test's log. +func Dir(t testing.TB, repository string) string { + t.Helper() + if root := os.Getenv(Env); root != "" { + dir := filepath.Join(root, repository) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+ + "judged here: %v", repository, Env, root, err) + } + t.Logf("judged against %s as cloned beside this check", dir) + return dir + } + from := filepath.Join(Captured(), repository) + if _, err := os.Stat(from); err != nil { + t.Fatalf("no captured copy of %s at %s: %v", repository, from, err) + } + dir := filepath.Join(t.TempDir(), repository) + err := filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix)) + if d.IsDir() { + return os.MkdirAll(into, 0o755) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) + } + t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+ + "judges it against the repository's own clone", repository) + return dir +} + +// Catalogue is the catalogue's modules directory, as Dir finds the catalogue. +func Catalogue(t testing.TB) string { + t.Helper() + return filepath.Join(Dir(t, "mesh-catalog"), "modules") +} + +// Captured is this repository's testdata/beside, found from this file rather than from the working +// directory, so a test in any package reaches it. +func Captured() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside") +}