Copy mesh-controller's catalogue tests that read mesh-catalog, and internal/beside, from 6c2a8d62 unchanged (issue 1379)

A pure copy of 30 test files and internal/beside/beside.go; they do not compile until the next commit rewrites their imports. Source: novox/mesh-controller 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1, internal/catalogue and internal/beside.
This commit is contained in:
2026-10-20 08:21:38 +00:00
parent 781ba6aeb4
commit 8a4381d0c6
31 changed files with 4689 additions and 0 deletions
@@ -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)))
}
}
}
+38
View File
@@ -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)
}
}
+87
View File
@@ -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 <nil>", 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 <nil>".
}
}
// 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.
+80
View File
@@ -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")
}
}
+138
View File
@@ -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)
}
}
}
+69
View File
@@ -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)
}
}
}
+135
View File
@@ -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=<b>,place=<p>}`, 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)
}
}
}
+158
View File
@@ -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 <attribute>=<value>",
"${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)
}
}
+71
View File
@@ -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)
}
}
+165
View File
@@ -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=<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)
}
}
+368
View File
@@ -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/<module> 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
}
+85
View File
@@ -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)
}
}
+98
View File
@@ -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)
}
}
+68
View File
@@ -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 "))
}
}
+104
View File
@@ -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)
}
}
+124
View File
@@ -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
}
+216
View File
@@ -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)
}
}
+144
View File
@@ -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
}
+122
View File
@@ -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)
}
}
}
}
+75
View File
@@ -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 - "))
}
}
+137
View File
@@ -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)
}
}
@@ -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)
}
}
}
+327
View File
@@ -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
}
+426
View File
@@ -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)
}
}
+311
View File
@@ -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)
}
}
}
+340
View File
@@ -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)
}
}
+86
View File
@@ -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)
}
}
+117
View File
@@ -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:<key>}, 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)
}
}
+125
View File
@@ -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>.<name>`},
{`{"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)
}
}
+271
View File
@@ -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)
}
}
+95
View File
@@ -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")
}