diff --git a/go/catalogue/a_service_process_test.go b/go/catalogue/a_service_process_test.go new file mode 100644 index 0000000..1e25276 --- /dev/null +++ b/go/catalogue/a_service_process_test.go @@ -0,0 +1,166 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// novox/hq issue 213 (ADR 0188 §1, §3): a module's own Go service is a bundle the host runs as a +// process, not an image. Each test holds one thing that had to change in the composer for the +// controller to be declared that way. + +const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + +// aServiceModule is the controller's shape in miniature: it answers tools of its own, its code is a +// Go bundle a process runs as an account it declares, its secrets belong to that account, it +// prepares its state, and its process replaces the container it used to run as. +func aServiceModule(t *testing.T) Manifest { + t.Helper() + raw := `{ + "module": "svc", "version": "1", "tools": ["status"], "prepares": true, + "own-secrets": {"store": "${dir:state}/store"}, + "secrets-owner": "svc", + "resources": [ + {"id": "state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "svc"}, + {"id": "service", "type": "process", "name": "svc", "artifact": "code", + "run": ["./svc", "serve"], "user": "svc", "replaces": ["server"], + "env": {"SVC_STORE_FILE": "${dir:state}/store", "SVC_STORE_PORT": "${seat:mesh-store:5432}"}}, + {"id": "account", "type": "user", "name": "svc", "shell": "/usr/bin/nologin", "home": "/var/lib/svc"} + ], + "build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch", + "from": "cmd/svc", "binary": "svc"}]} + }` + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatalf("the service's manifest is refused: %v", err) + } + resolved, err := m.Resolve([]Built{{Name: "code", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "svc/code@" + aServiceDigest, Digest: aServiceDigest}}) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func composeTheService(t *testing.T, with Rendering) []map[string]any { + t.Helper() + with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}} + with.ArtifactStore = "anchor.internal:5100" + out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with) + if err != nil { + t.Fatalf("the service does not compose: %v", err) + } + return out +} + +func indexOf(out []map[string]any, id string) int { + for i, r := range out { + if r["id"] == id { + return i + } + } + return -1 +} + +// A module that declares tools has every bundle served by the node's runtime unless it says +// otherwise — and the controller declares the verbs it answers as tools. Its service bundle is run +// by its own process; launched a second time by the runtime it would be a second controller +// pretending to be an MCP server. +func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) { + m := aServiceModule(t) + if len(m.Bundles) != 1 { + t.Fatalf("the service's bundle was not kept: %+v", m.Bundles) + } + if loads := m.Bundles[0].Loads; len(loads) != 0 { + t.Fatalf("the runtime would launch the service's own bundle as tools: %v", loads) + } + // And a bundle no resource runs still is served, as a module declaring tools always had it. + tools := Manifest{Module: "t", Version: "1", Tools: []string{"x"}, + Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/t"}}}} + resolved, err := tools.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "t/tools@" + aServiceDigest, Digest: aServiceDigest}}) + if err != nil { + t.Fatal(err) + } + if loads := resolved.Bundles[0].Loads; len(loads) != 1 || loads[0] != "t" { + t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads) + } +} + +// The account is created before anything is given to it. Its secrets are mesh-computed and so +// placed before the module's own resources; given to a user the machine did not have yet, they were +// refused on the first apply and the process started without them. +func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) { + out := composeTheService(t, Rendering{}) + account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store")) + if account < 0 || secret < 0 { + t.Fatalf("the account or the secret is missing: %v", out) + } + if account > secret { + t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d", + account, secret) + } + if owner := out[secret]["owner"]; owner != "svc" { + t.Errorf("the secret belongs to %v, not the account its process runs as", owner) + } +} + +// The process is the module's program; its preparation is the same program asked to prepare, as a +// step before it — with the same account and environment, and handing nothing over. +func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) { + out := composeTheService(t, Rendering{}) + step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service") + if step < 0 || process < 0 || step > process { + t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out) + } + s := out[step] + if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" { + t.Errorf("the preparation is not a run-once process: %v", s) + } + if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` { + t.Errorf("the preparation runs %s", run) + } + if s["user"] != "svc" || s["source"] != out[process]["source"] { + t.Errorf("the preparation does not run the same bundle as the same account: %v", s) + } + if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil { + t.Errorf("the preparation is not given the process's environment: %v", s["env"]) + } + if _, has := s["replaces"]; has { + t.Errorf("the preparation would hand over what the process replaces: %v", s) + } + if _, has := s["args"]; has { + t.Errorf("the preparation carries a container's args: %v", s) + } +} + +// What the process replaces is named as the host recorded it, `.`; unprefixed, the host +// matches nothing and removes the container first, as before. +func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) { + out := composeTheService(t, Rendering{}) + p := out[indexOf(out, "svc.service")] + if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` { + t.Fatalf("the process replaces %s", got) + } +} + +func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) { + for what, resource := range map[string]string{ + "a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`, + "a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`, + "something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`, + "another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`, + "not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`, + } { + raw := `{"module":"m","version":"1","resources":[` + resource + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") { + t.Errorf("replaces on %s was accepted: %v", what, err) + } + } + ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a process replacing what its module no longer declares was refused: %v", err) + } +} diff --git a/go/catalogue/accesses_test.go b/go/catalogue/accesses_test.go new file mode 100644 index 0000000..b95250a --- /dev/null +++ b/go/catalogue/accesses_test.go @@ -0,0 +1,108 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the +// acquisition managers, a download client — that must share directories on one machine. Written +// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which +// is right in general and wrong here: sharing those directories is the whole point of the stack. +// +// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access +// is not a resource and never enters the duplicate-owner map, so this is the exact case the +// resolver refuses above when the same path is owned — and does not, when it is merely accessed. +func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessReadWrite}} + b := mod("b", nil, nil, nil) + b.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessRead}} + + got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) + if err != nil { + t.Fatalf("two modules accessing one operator-owned path were refused: %v", err) + } + + // And each accessing module's grant reaches the machine as its own `access` resource, so the + // host can find the path present or refuse — both naming the one operator-owned path. + var accesses int + for _, r := range mustDeclare(t, got) { + if r["type"] == "access" && r["path"] == "/services/media/downloads" { + accesses++ + } + } + if accesses != 2 { + t.Errorf("expected both modules' accesses in the declaration, got %d", accesses) + } +} + +// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside: +// two modules OWNING one path is still refused, exactly as before accesses existed. +func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + + _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) + if err == nil { + t.Fatal("two modules owning the same directory were both assigned") + } + if !strings.Contains(err.Error(), "/services/media/movies") { + t.Errorf("the refusal does not name the path: %v", err) + } +} + +// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path +// another module was told to expect the operator to provide would create and chown it — and the +// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction +// machine-checkable rather than a convention nobody enforces. +func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { + owns := mod("owns", nil, nil, nil) + owns.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + uses := mod("uses", nil, nil, nil) + uses.Accesses = []Access{{Path: "/services/media/movies", Mode: AccessRead}} + + _, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), World{}) + if err == nil { + t.Fatal("a module owning a path another accesses was allowed") + } + if !strings.Contains(err.Error(), "/services/media/movies") || + !strings.Contains(err.Error(), "the operator's") { + t.Errorf("the refusal does not explain the ownership conflict: %v", err) + } +} + +// A manifest states an access's extent, the way a listening port states its source. An absolute +// path and a mode of read or read-write; anything else is refused rather than acted on wrongly. +func TestAnAccessIsValidated(t *testing.T) { + good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`) + if _, err := ParseManifest(good); err != nil { + t.Fatalf("a valid access was refused: %v", err) + } + + // Mode is optional and narrows to read — the safe default, because the danger with an access + // is being given more than was meant, not less. + bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`) + m, err := ParseManifest(bare) + if err != nil { + t.Fatalf("an access with no mode was refused: %v", err) + } + if m.Accesses[0].At() != AccessRead { + t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead) + } + + relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`) + if _, err := ParseManifest(relative); err == nil { + t.Error("a relative access path was accepted") + } + + wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`) + if _, err := ParseManifest(wrong); err == nil { + t.Error("an access with an unknown mode was accepted") + } +} diff --git a/go/catalogue/adoption_test.go b/go/catalogue/adoption_test.go new file mode 100644 index 0000000..8630fa4 --- /dev/null +++ b/go/catalogue/adoption_test.go @@ -0,0 +1,683 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" +) + +// novox/hq ADR 0100: on an adopted node the found firewall stays in force. The mesh declares +// openings where it would have loaded a filter, and guards its own ports in a table that only +// refuses. + +// hub is the private network's generator on the hub: it opens the hub's port from anywhere. +type hub struct{} + +func (hub) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf", + "content": "[Interface]\n"}}, true, nil +} +func (hub) Listens(string) ([]Listening, error) { + return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere}}, nil +} + +// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network, +// a served module and the filter module. +func anAdoptedAnchor() Resolution { + return Resolution{Node: "anchor", Modules: []Manifest{ + {Module: "network", Computed: "overlay"}, + {Module: "postgres", Guards: []int{5432}, + Listens: []Listening{{Port: 5432, From: FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}}}}, + {Module: "lavinmq", Guards: []int{15672}, + Listens: []Listening{{Port: 5671, From: FromMesh}, {Port: 5672, From: FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker", + "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}}, + {Module: "distribution", + Listens: []Listening{{Port: 5000, From: FromMesh}}, + Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry", + "ports": []any{"5000"}}}}, + {Module: "hello-web", + Listens: []Listening{{Port: 8080, From: FromEverywhere}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web", + "ports": []any{"8080"}}}}, + {Module: "helper", Listens: []Listening{{Port: 9000, From: FromMachine}}}, + {Module: "nftables", Filtering: &Filtering{Into: "/etc/nftables.conf"}, + Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service", + "state": "running", "restart-on": []any{"filtering"}}}}, + }} +} + +func anchorRendering(adopted bool) Rendering { + return Rendering{ + Generators: map[string]Generator{"overlay": hub{}}, + Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}}, + Settings: SettingsBy{"distribution": {{From: "node anchor", + Placement: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}}, + Mesh: []string{"10.42.0.1"}, + Foundation: []int{5671}, + // What the machine reported faces outside, which every rule in the filter is written + // around (novox/hq ADR 0140). + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Adopted: adopted, + // Genesis takes the foundation's modules. + Taken: map[string]bool{"postgres": true, "lavinmq": true}, + } +} + +func byID(resources []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range resources { + out[r["id"].(string)] = r + } + return out +} + +func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) { + composed, err := anAdoptedAnchor().Compose(anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + want := map[string]map[string]any{ + // The hub's port, from anywhere, received. + "adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp", + "from": "everywhere", "path": "incoming"}, + // The store's port from the private network only, and forwarded: a container publishes it. + "adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5432}, + // The bus from anywhere: a node enrols over it before it has a private address. + "adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5671}, + "adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5672}, + // The registry from anywhere, by its node's exposure setting. + "adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5000}, + // A published port names the machine port and the container port it is forwarded to. + "adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 8080}, + } + for id, fields := range want { + opening, ok := got[id] + if !ok { + t.Errorf("no %s among %v", id, keys(got)) + continue + } + if opening["type"] != "opening" { + t.Errorf("%s is a %v", id, opening["type"]) + } + for k, v := range fields { + if opening[k] != v { + t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v) + } + } + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil { + t.Errorf("an opening nothing asked for: %s", id) + } + } + // A port for this machine only opens nothing, and the management port is not opened at all. + for id := range got { + if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") { + t.Errorf("%s is opened", id) + } + } + + // And openings come first, in the order the machine applies them. + if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") { + t.Errorf("openings are not first: %v", composed.Resources[0]["id"]) + } +} + +func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) { + composed, err := anAdoptedAnchor().Compose(anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + for _, r := range composed.Resources { + if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") { + t.Fatalf("an adopted node is declared the filter module's %v", r["id"]) + } + if content, _ := r["content"].(string); strings.Contains(content, "policy drop") { + t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"]) + } + // Nothing but refusals: the only accept in the guard is its policy. + if content, _ := r["content"].(string); r["id"] == GuardID() && + strings.Count(content, "accept") != 1 { + t.Fatalf("the guard holds an accept:\n%s", content) + } + } + got := byID(composed.Resources) + guard := got[GuardID()] + if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil { + t.Fatalf("no guard: %v", keys(got)) + } + if guard["content"] != AsGuard([]int{5432, 5672, 15672}) { + t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s", + guard["content"]) + } + // The tool that loads it comes first, and the table after it: a node joining adopted has no + // filter module and may have no nft. + pkg, table := -1, -1 + for i, r := range composed.Resources { + switch r["id"] { + case GuardPackageID(): + pkg = i + if r["type"] != "package" || r["package"] != "nftables" { + t.Fatalf("the guard's package is %v", r) + } + case GuardID(): + table = i + } + } + if pkg < 0 || pkg > table { + t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table) + } + // A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the + // table and leave the ports unguarded until it is loaded again. Only a changed unit restarts. + if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) || + !reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) { + t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v", + got[GuardRunningID()]) + } + // Nothing of the mesh's own is anybody's to hold. + for id, module := range composed.Owner { + if strings.HasPrefix(id, AdoptionPrefix) { + t.Fatalf("%s is owned by %s", id, module) + } + } +} + +func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) { + composed, err := anAdoptedAnchor().Compose(anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if got["nftables.filtering"] == nil || got["nftables.load"] == nil { + t.Fatalf("a converged node lost its filter: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, AdoptionPrefix) { + t.Fatalf("a converged node is declared %s", id) + } + } + plain, err := anAdoptedAnchor().Declaration(anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(plain) + b, _ := json.Marshal(composed.Resources) + if string(a) != string(b) { + t.Fatal("Compose and Declaration disagree on a converged node") + } +} + +// The table the installer raises and the controller declares, character for character. +func TestTheGuardIsExactlyThisTable(t *testing.T) { + const golden = `table inet mesh_guard {} +delete table inet mesh_guard +table inet mesh_guard { + chain prerouting { + type filter hook prerouting priority raw; policy accept; + fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop + } +} +` + if got := AsGuard([]int{15672, 5432}); got != golden { + t.Fatalf("the guard changed:\n%s", got) + } + const unit = `[Unit] +Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100) +DefaultDependencies=no +Wants=network-pre.target +Before=network-pre.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/mesh/guard.nft +ExecReload=nft -f /etc/mesh/guard.nft +ExecStop=nft delete table inet mesh_guard + +[Install] +WantedBy=multi-user.target +` + if got := GuardUnitText(); got != unit { + t.Fatalf("the guard's unit changed:\n%s", got) + } + if GuardResources(nil) != nil { + t.Fatal("a guard with nothing to guard is an empty set nft refuses to load") + } +} + +func TestAGuardedPortMustBeAPort(t *testing.T) { + if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil { + t.Fatalf("guards is refused: %v", err) + } + if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil { + t.Fatal("guarding port 0 was accepted") + } +} + +func keys[V any](m map[string]V) []string { + return sortedKeys(m) +} + +// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place +// that uses the port reads it from there: the container, the filter, the openings, the guard. +func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) { + given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}} + for _, adopted := range []bool{true, false} { + with := anchorRendering(adopted) + with.Given = given + with.Ports["postgres"] = map[int]int{5432: 5433} + composed, err := anAdoptedAnchor().Compose(with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) { + t.Fatalf("the store's container publishes %v", ports) + } + if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports, + []any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) { + t.Fatalf("the broker's container publishes %v", ports) + } + if !adopted { + filter, _ := got["nftables.filtering"]["content"].(string) + if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") { + t.Fatalf("the filter does not use the given port:\n%s", filter) + } + continue + } + if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 { + t.Fatalf("no opening for the given port: %v", keys(got)) + } + if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) { + t.Fatalf("the guard does not guard the given ports:\n%s", guard) + } + } +} + +func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) { + store := anAdoptedAnchor().Modules[1] + node := func(v any) []Layer { + return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}} + } + if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil || + got[5432] != 5433 { + t.Fatalf("a node's given port was not read: %v %v", got, err) + } + if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer, + Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil { + t.Fatal("a port given for the whole mesh was accepted") + } + if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil { + t.Fatal("a port the module neither listens on, publishes nor guards was given") + } + if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil { + t.Fatal("a machine port that is not a port was given") + } + if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil { + t.Fatal("ssh's port was given") + } + broker := anAdoptedAnchor().Modules[2] + if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700), + "5672": float64(5700)})); err == nil { + t.Fatal("one machine port was given for two of the module's ports") + } + if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 { + t.Fatalf("a given port is called stray: %v", stray) + } +} + +// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken +// module publishes that the filter admits from the private network only, and the ports its +// manifest guards. A module assigned but not taken is not guarded: its port may still be the +// predecessor's. +func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) { + guardOf := func(with Rendering) string { + t.Helper() + composed, err := anAdoptedAnchor().Compose(with) + if err != nil { + t.Fatal(err) + } + content, _ := byID(composed.Resources)[GuardID()]["content"].(string) + return content + } + + // The broker taken, the store not: the broker's plain port follows from its listens (from + // the mesh, published), its management port from its manifest; the store is not guarded, and + // neither is the bus, which the mesh needs from everywhere. + with := anchorRendering(true) + with.Taken = map[string]bool{"lavinmq": true} + if got := guardOf(with); got != AsGuard([]int{5672, 15672}) { + t.Fatalf("the guard is not the taken broker's ports:\n%s", got) + } + + // A taken module publishing a port admitted from everywhere is not guarded; one admitted from + // the mesh is. The registry is exposed everywhere on this node, and hello-web listens from + // everywhere. + with.Taken = map[string]bool{"distribution": true, "hello-web": true} + if got := guardOf(with); got != "" { + t.Fatalf("a port admitted from everywhere is guarded:\n%s", got) + } + with.Settings = nil + if got := guardOf(with); got != AsGuard([]int{5000}) { + t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got) + } + + // Nothing taken, nothing guarded — and no guard at all rather than an empty set. + with = anchorRendering(true) + with.Taken = nil + if got := guardOf(with); got != "" { + t.Fatalf("an untaken store is guarded:\n%s", got) + } + + // A given port is followed: where the machine put it is what is refused. + with = anchorRendering(true) + with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}} + with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682} + if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) { + t.Fatalf("the guard does not follow the given ports:\n%s", got) + } +} + +// A mapping bound to loopback is not published to anything off the machine — in either address +// family — and an address's own colons never shift the ports. +func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) { + got := Published([]map[string]any{{"type": "container", "ports": []any{ + "127.0.0.1:15672:15672", "[::1]:8080:80", "localhost:9090:90", + "[::]:8443:443", "0.0.0.0:5000:5000", "5353:53/udp"}}}) + want := map[string]map[int]int{"tcp": {8443: 443, 5000: 5000}, "udp": {5353: 53}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("published is %v, want %v", got, want) + } +} + +// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine +// itself listens where its software was told to, so giving it a machine port is refused. +func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) { + onTheMachine := Manifest{Module: "daemon", + Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}} + layers := []Layer{{From: "node anchor", + Placement: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}} + _, err := GivenPorts(onTheMachine, layers) + if err == nil || !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port no container publishes was given: %v", err) + } +} + +// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded. +// An opening from everywhere beside a guard dropping it is one statement refusing the other. +func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) { + with := anchorRendering(true) + with.Settings["postgres"] = []Layer{{From: "node anchor", + Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}} + composed, err := anAdoptedAnchor().Compose(with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != OpeningFromEverywhere { + t.Fatalf("the store's port is not opened to everyone: %v", o) + } + if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) { + t.Fatalf("a port opened to everyone is still guarded:\n%s", guard) + } +} + +// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon +// holds 22 — and says it listens on the machine side of that mapping, which is what anything +// reaching it dials. +func aForge() Manifest { + return Manifest{Module: "forge", + Provides: []Offer{{Name: "git-over-ssh", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}}, + Listens: []Listening{{Port: 3000, From: FromMesh}, {Port: 2222, From: FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge", + "ports": []any{"3000", "2222:22"}}}} +} + +// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way +// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module +// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written +// here because everything below — the filter, the openings, the guard, what a consumer is told — +// reads that map, and a given port that the lookup does not find moves the container's mapping +// and nothing else. +// +// It stands in for the plan only where the plan does not allocate: a port the manifest already +// placed, or one a node was given. For a short form with no given port the real plan asks the +// inventory for a machine port and may come back with one from the pool, which needs a store and +// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts +// this helper, not the mesh; keep the assertions to the ports under test. +func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int { + out := map[int]int{} + for _, l := range m.Listens { + if at, is := given[l.Port]; is { + out[l.Port] = at + continue + } + at, _ := m.MachineSide(l.Port) + out[l.Port] = at + } + return out +} + +// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module +// publishes, so a node may move it — and a module may name a mapping by either end. +func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) { + forge := aForge() + node := func(v any) []Layer { + return []Layer{{From: "node anchor", Placement: map[string]any{PortsSetting: v}}} + } + + // The machine side — the number this module says it listens on, and the one the predecessor + // had somewhere else. Answered under 2222, the end the module itself names, which is what + // every reader of this map asks for. One entry, not two: a second key for the same answer is + // an entry another mapping's reader could find instead. + given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)})) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) { + t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want) + } + + // The container's own port names the same mapping and means the same thing — and is filed + // under the same name, because the module's name for it has not changed. + if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil || + !reflect.DeepEqual(inside, map[int]int{2222: 222}) { + t.Fatalf("the container's end of the mapping was given %v: %v", inside, err) + } + + // A short form is published on the number it names, and is unchanged by any of this. + if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil || + short[3000] != 2999 { + t.Fatalf("the short form was given %v: %v", short, err) + } + + // A port no container publishes is still refused, in the same words. + if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil || + !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port the forge does not publish was given: %v", err) + } + + // And the two ends of one mapping given two different numbers is one setting contradicting + // the other: the machine publishes it once. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(300)})); err == nil || + !strings.Contains(err.Error(), "one mapping") { + t.Fatalf("the two ends of one mapping were given different ports: %v", err) + } + // Said at both ends with the same number, it is still said twice, and refused where every + // other repeated machine port is — as the inventory refuses it when the setting is stored, + // which is the layer that sees it first. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "to both its 22 and its 2222") { + t.Fatalf("one mapping given one machine port at both ends: %v", err) + } + // A number that names two different mappings names neither: which one to move is not said. + twice := aForge() + twice.Resources[0]["ports"] = []any{"22", "2222:22"} + if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("a number naming two of the module's mappings was accepted: %v", err) + } + + // And the same refusal when the number naming two mappings is not the one the setting used + // but the one the answer would be filed under. Here `80` is the module's own name for a + // mapping, and two mappings wear it; filing an answer there is one container's port standing + // where the other's is read, and both containers then publish it. + shared := Manifest{Module: "gallery", + Listens: []Listening{{Port: 80, From: FromMesh}}, + Resources: []map[string]any{ + {"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}}, + {"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}} + if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("two containers were put on one machine port: %v", err) + } + + // A module whose mappings chain — one's machine side is another's container port — keeps them + // apart, because each is filed under the port the module names it by and neither name is + // shared. Given both, each moves on its own and neither overwrites the other. + chained := Manifest{Module: "chain", + Listens: []Listening{{Port: 80, From: FromMesh}, {Port: 9090, From: FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain", + "ports": []any{"8080:80", "9090:8080"}}}} + both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)})) + if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) { + t.Fatalf("chained mappings were given %v: %v", both, err) + } + if moved := givenOuter("8080:80", both); moved != "1234:80" { + t.Fatalf("the first mapping moved to %q, and it was given 1234", moved) + } + if moved := givenOuter("9090:8080", both); moved != "5678:8080" { + t.Fatalf("the second mapping moved to %q, and it was given 5678", moved) + } +} + +// And the number reaches everything derived from it. The fault this is written against moved the +// container's mapping alone: the filter opened the port the software had left, the adopted node's +// opening named it too, the guard refused it, and a consumer was sent to it. +func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) { + forge := aForge() + given, err := GivenPorts(forge, []Layer{{From: "node anchor", + Placement: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}}) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + r := Resolution{Node: "anchor", Modules: []Manifest{forge}} + with := Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)}, + Given: map[string]map[int]int{"forge": given}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Taken: map[string]bool{"forge": true}, + } + + // What the runtime is handed: the machine's own port on the outside, the container's within. + composed, err := r.Compose(with) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + got := byID(composed.Resources) + if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) { + t.Fatalf("the forge's container publishes %v", ports) + } + + // What the filter would open, were the node converged. + rules, err := r.Rules(with) + if err != nil { + t.Fatal(err) + } + var opened []int + for _, rule := range rules { + opened = append(opened, rule.Port) + } + // Only the moved port is asserted: the forge's other port is a short form the real plan would + // allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and + // not the mesh's. + if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) { + t.Fatalf("the filter opens %v, not where the machine puts the forge", opened) + } + + // And what it is declared instead, adopted: an opening on the machine's port, naming the + // container's port on the forwarded path — a published port is forwarded, never received. + opening := got[OpeningID("tcp", 222, PathForwarded)] + if opening == nil || opening["to"] != 22 || opening["from"] != OpeningFromMesh { + t.Fatalf("no opening for the port this node gave the forge: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-tcp-2222-") { + t.Errorf("an opening for the port the forge was moved off: %s", id) + } + } + + // The guard refuses it where the machine put it, and nothing where it used to be. + guard, _ := got[GuardID()]["content"].(string) + if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") { + t.Fatalf("the guard does not follow the given port:\n%s", guard) + } + + // And a consumer is sent to the same number, which is read from what the module serves. + if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 { + t.Fatalf("a consumer is told the forge answers on %v", told) + } +} + +// And the mapping itself moves under either name, because Rendering.Given is a map anybody +// composing a declaration hands in: keyed by the machine side, which is what a module declaring +// 2222 calls its port, only the outside moves and the container's own port stays as written. +func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) { + for _, c := range []struct { + written string + given map[int]int + want string + }{ + {"2222:22", map[int]int{2222: 222}, "222:22"}, + {"2222:22", map[int]int{22: 222}, "222:22"}, + {"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"}, + {"2222:22", map[int]int{3000: 2999}, "2222:22"}, + {"2222:22", nil, "2222:22"}, + } { + if got := givenOuter(c.written, c.given); got != c.want { + t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want) + } + } +} + +// The same on a node that was given nothing, which is where the derivation was never at fault: +// a long-form mapping is published where the manifest put it, and an adopted node opens that port +// on the forwarded path like any other. What broke it was the number reaching only the mapping. +func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) { + forge := aForge() + r := Resolution{Node: "anchor", Modules: []Manifest{forge}} + composed, err := r.Compose(Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + }) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + opening := got[OpeningID("tcp", 2222, PathForwarded)] + if opening == nil || opening["to"] != 22 { + t.Fatalf("no opening for the forge's published ssh port: %v", keys(got)) + } +} diff --git a/go/catalogue/agent_account_test.go b/go/catalogue/agent_account_test.go new file mode 100644 index 0000000..90efb04 --- /dev/null +++ b/go/catalogue/agent_account_test.go @@ -0,0 +1,119 @@ +package catalogue + +import ( + "testing" +) + +// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account +// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become +// root only where it is the agents' own. + +func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) { + facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "") + if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" { + t.Errorf("with no agent account named, agents run as the operator: %v", facts) + } + facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "") + if facts["agent-home"] != "/srv/ops" { + t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts) + } + facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "") + if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever { + t.Errorf("a named agent account is the agents', never root: %v", facts) + } + if facts["account"] != "ops" { + t.Errorf("the operator account is still the operator's: %v", facts) + } + facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "") + if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever { + t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts) + } + if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has { + t.Error("a machine with no account at all names an agent account") + } +} + +// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its +// own; its directory under that home, owned by it. +const agentModule = `{"module": "agent", "version": "1", "resources": [ + {"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}", + "root": "${machine:agent-root}"}, + {"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700", + "owner": "${machine:agent-account}"} +]}` + +func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { + m, err := ParseManifest([]byte(agentModule)) + if err != nil { + t.Fatal(err) + } + compose := func(r Resolution, with Rendering) (user, home map[string]any) { + t.Helper() + r.Node, r.Modules = "anchor", []Manifest{m} + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + return fileNamed(out, "agent.account"), fileNamed(out, "agent.home") + } + + user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true}) + if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" { + t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user) + } + if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" { + t.Errorf("the agent's directory is under its own home, its own: %v", home) + } + + user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true}) + if user["home"] != "/srv/agent" { + t.Errorf("an agent account named with a home of its own is made there: %v", user) + } + + user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{}) + if _, sent := user[RootField]; sent || user["name"] != "agent" { + t.Errorf("an older engine, which parses strictly, is sent root: %v", user) + } + + user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true}) + if _, sent := user[RootField]; sent || user["name"] != "ops" { + t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user) + } + if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" { + t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) + } +} + +func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + envOf := func(r Resolution) map[string]string { + t.Helper() + r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)} + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if process == nil { + t.Fatal("no runtime process was composed") + } + return process["env"].(map[string]string) + } + env := envOf(Resolution{Account: "ops", AgentAccount: "agent"}) + if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" { + t.Errorf("the runtime is not told whom agents run as: %v", env) + } + env = envOf(Resolution{Account: "ops"}) + if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" { + t.Errorf("with no agent account, agents run as the operator: %v", env) + } + env = envOf(Resolution{}) + if _, set := env[RuntimeAgentAccount]; set { + t.Errorf("a machine with no account names an agent account: %v", env) + } + if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"}, + Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 { + t.Error("a bundle may tell the runtime whom agents run as") + } +} diff --git a/go/catalogue/artifact_store_alias_test.go b/go/catalogue/artifact_store_alias_test.go new file mode 100644 index 0000000..761d476 --- /dev/null +++ b/go/catalogue/artifact_store_alias_test.go @@ -0,0 +1,18 @@ +package catalogue + +import "testing" + +// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's +// aliases loaded, the former name resolves to the seat. +func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) { + was := aliases + t.Cleanup(func() { aliases = was }) + UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"}) + seat, known := SeatNamed("the-artifact-store") + if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" { + t.Fatalf("the former name did not resolve: %+v %v", seat, known) + } + if _, known := SeatNamed("mesh-artifact-store"); !known { + t.Fatal("the seat is not in the set under its name") + } +} diff --git a/go/catalogue/artifacts_test.go b/go/catalogue/artifacts_test.go new file mode 100644 index 0000000..3d0ae70 --- /dev/null +++ b/go/catalogue/artifacts_test.go @@ -0,0 +1,137 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A build is recorded by what it is; where it is pushed is composed where it is used (novox/hq +// 04-ISSUES/102). + +var digest = "sha256:" + strings.Repeat("d", 64) + +func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { + cases := map[string]string{ + "anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest, + "localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest, + "http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest, + ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest, + digest: digest, + "@novox/sdk@1.2.3": "@novox/sdk@1.2.3", + "gitea/gitea@" + digest: "gitea/gitea@" + digest, + "https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest, + } + for announced, want := range cases { + if got := Recorded(announced); got != want { + t.Errorf("Recorded(%q) = %q, want %q", announced, got, want) + } + } +} + +func TestARecordedReferenceIsRoutedThroughTheStoreAsItIsNow(t *testing.T) { + if got := Routed(ArtifactStoreScheme+"gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest { + t.Errorf("an image is fetched as %q", got) + } + if got := Routed(ArtifactStoreScheme+"gitea/config/blobs/"+digest, "anchor.internal:5101"); got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { + t.Errorf("an archive is fetched as %q", got) + } + if got := Routed("gitea/gitea@"+digest, "anchor.internal:5101"); got != "gitea/gitea@"+digest { + t.Errorf("a reference that is not the store's was routed: %q", got) + } + // One recorded before references were kept without their address follows the store too. + if got := Rerouted("anchor.internal:5100/gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest { + t.Errorf("a reference recorded with the old address stays there: %q", got) + } +} + +// **The address is composed into a declaration, and the record never carries it.** +func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { + m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"}, + {"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"}, + {"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest}, + }, Build: &Build{Artifacts: []Artifact{ + {Name: "server", Kind: ArtifactImage, From: "Dockerfile"}, + {Name: "config", Kind: ArtifactArchive, From: "config"}, + }}} + resolved, err := m.Resolve([]Built{ + {Name: "server", Kind: ArtifactImage, Reference: ArtifactStoreScheme + "gitea/server@" + digest}, + {Name: "config", Kind: ArtifactArchive, Reference: ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest}, + }) + if err != nil { + t.Fatal(err) + } + r := Resolution{Node: "anchor", Modules: []Manifest{resolved}} + + out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest { + t.Errorf("the image the mesh built is fetched as %v", got) + } + if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { + t.Errorf("the archive the mesh built is fetched from %v", got) + } + if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest { + t.Errorf("an image from a public registry was routed through the store: %v", got) + } + // The manifest the mesh holds still says what it is, not where it was fetched from. + if got := resolved.Resources[0]["image"]; got != ArtifactStoreScheme+"gitea/server@"+digest { + t.Errorf("composing wrote the address into the catalogue's copy: %v", got) + } + + // And the store moves: the same record, another address, without a rebuild. + out, err = r.Declaration(Rendering{ArtifactStore: "laptop.internal:5000"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest { + t.Errorf("after the store moved, the image is still fetched as %v", got) + } +} + +func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) { + m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", + "image": ArtifactStoreScheme + "gitea/server@" + digest}, + }} + _, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(Rendering{}) + if err == nil || !strings.Contains(err.Error(), "no artifact store") { + t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err) + } +} + +// **A reference recorded with an address before this follows the store too** — when the mesh +// built it. A module running an image straight from a public registry under its own name is left +// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called. +func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) { + m := Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-keycloak", + "image": "anchor.internal:5100/keycloak/server@" + digest}, + {"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream", + "image": "quay.io/keycloak/keycloak@" + digest}, + }} + r := Resolution{Node: "anchor", Modules: []Manifest{m}} + out, err := r.Declaration(Rendering{ + ArtifactStore: "anchor.internal:5101", + Built: map[string]bool{"keycloak/server": true}, + }) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest { + t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got) + } + if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest { + t.Errorf("a public image was re-routed through the store: %v", got) + } + // Nothing known to be built: nothing re-routed, nothing refused. + out, err = r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest { + t.Errorf("with no build record, a reference was rewritten: %v", got) + } +} diff --git a/go/catalogue/backup_placement_test.go b/go/catalogue/backup_placement_test.go new file mode 100644 index 0000000..ba566ca --- /dev/null +++ b/go/catalogue/backup_placement_test.go @@ -0,0 +1,126 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as +// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded +// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text. + +const ( + oldBackupText = "# lines\n${contribution:node-backup:backup}" + oldDataText = "# data\n${contribution:node-backup:data}" + placedBackupTxt = "# lines\n${backup:lines}" + placedDataTxt = "# data\n${backup:data}" + backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"}, + {"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}` +) + +func dataModule(name string) string { + return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], + "data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}` +} + +// backupsOn is a machine of a holder whose files say these texts and the modules that declare data. +func backupsOn(t *testing.T, lines, data string, modules ...string) Resolution { + t.Helper() + all := []Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))} + names := []string{"restic"} + for _, name := range modules { + all = append(all, mustParse(t, dataModule(name))) + names = append(names, name) + } + r, err := Resolve(shelf(all...), names, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +var backupModules = []string{"alpha", "beta", "gamma"} + +// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort. +func gammaLeftOut(with Rendering) Rendering { + with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"} + return with +} + +func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) { + // The old form is an unmoved holder: its placeholders are the controller's alone. + oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1) + all := []Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))} + for _, name := range backupModules { + all = append(all, mustParse(t, dataModule(name))) + } + old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), World{}) + if err != nil { + t.Fatal(err) + } + old.Account = "op" + wantLines, wantData := fileOf(t, old, gammaLeftOut(Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(Rendering{}), "restic.data-declared") + if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") { + t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData) + } + moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...) + // Not handed: the controller fills the new placeholders with the old text. + if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.declared"); got != wantLines { + t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines) + } + if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.data-declared"); got != wantData { + t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData) + } + // Handed: the plan carries the same text as placement, and the files are the client's. + c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000"))) + p := c.Plans["restic"].Placement + if got := "# lines\n" + p["backup:lines"]; got != wantLines { + t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines) + } + if got := "# data\n" + p["backup:data"]; got != wantData { + t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData) + } + if len(p) != 2 { + t.Errorf("the plan's placement is %v", p) + } +} + +func TestAModuleAddedOrTakenAwayChangesTheBackupPlan(t *testing.T) { + plan := func(modules ...string) map[string]string { + c, _ := composeWith(t, backupsOn(t, placedBackupTxt, placedDataTxt, modules...), handingTo("store:5000")) + return c.Plans["restic"].Placement + } + two, three := plan("alpha", "beta"), plan("alpha", "beta", "gamma") + if reflect.DeepEqual(two, three) || strings.Contains(two["backup:lines"], "gamma") || !strings.Contains(three["backup:lines"], "# gamma\n") { + t.Errorf("two modules give %v, three give %v", two, three) + } +} + +func TestAnUnknownBackupKeyIsRefused(t *testing.T) { + r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...) + if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("handed, an unknown key is %v", err) + } + if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("not handed, an unknown key is %v", err) + } +} + +func fileOf(t *testing.T, r Resolution, with Rendering, id string) string { + t.Helper() + out, err := r.Declaration(with) + 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 "" +} diff --git a/go/catalogue/backup_test.go b/go/catalogue/backup_test.go new file mode 100644 index 0000000..4450ac2 --- /dev/null +++ b/go/catalogue/backup_test.go @@ -0,0 +1,47 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A backup contribution written by hand still names its module's own directories; one it does not +// declare is refused where it is written rather than reaching the holder as the literal text. (The +// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one, +// because a module on the shelf was written before.) +func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"pg","version":"1", + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`)) + if err == nil || !strings.Contains(err.Error(), "nowhere") { + t.Fatalf("a backup of an undeclared directory was accepted: %v", err) + } +} + +// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no +// data, is still backed up: its lines are placed as written, each module's under a comment naming it. +func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { + pg, err := ParseManifest([]byte(`{"module":"pg","version":"1", + "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`)) + if err != nil { + t.Fatal(err) + } + mail, err := ParseManifest([]byte(`{"module":"mail","version":"1", + "resources":[{"id":"spool","type":"directory","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) + if err != nil { + t.Fatal(err) + } + placed, _, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} { + if !strings.Contains(placed, want) { + t.Errorf("placed contributions lack %q:\n%s", want, placed) + } + } + if strings.Contains(placed, "${dir:") { + t.Errorf("a directory reached the holder unfilled:\n%s", placed) + } +} diff --git a/go/catalogue/bootstrap_cycle_test.go b/go/catalogue/bootstrap_cycle_test.go new file mode 100644 index 0000000..9cbbd67 --- /dev/null +++ b/go/catalogue/bootstrap_cycle_test.go @@ -0,0 +1,71 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The module that provides the artifact store may not be delivered through it. +// +// Building publishes to the store and the builder will not start without one, so a module that +// provides the store and also builds something asks the mesh to put an artifact into the thing +// that artifact is needed to create. On a mesh new enough to have no registry, that is a build +// that never returns (novox/hq 04-ISSUES/029). +func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + + `"provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` + + `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + + `"artifact":"registry","ports":["5000:5000"]}]}`)) + if err == nil { + t.Fatal("a registry module that builds its own image was accepted; the build has " + + "nowhere to publish until the module it belongs to is already running") + } + if !strings.Contains(err.Error(), "artifact-store") { + t.Fatalf("refused without naming the provision the cycle turns on: %v", err) + } +} + +// Naming the image directly is the way out, and must stay accepted. +func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + + `"provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + + `"image":"registry@sha256:` + + `266f282fabd7cd3df053ee7c658c77b42380d1a2f0d8e5a1c0d7a6d5b5c4a3b2",` + + `"ports":["5000:5000"]}]}`)) + if err != nil { + t.Fatalf("the one way an artifact store can be delivered was refused: %v", err) + } +} + +// And an ordinary module still builds whatever it likes. +func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"forge","version":"1",` + + `"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` + + `"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`)) + if err != nil { + t.Fatalf("an ordinary module was caught by a rule about the artifact store: %v", err) + } +} + +// A mapping that names an address still names the port, and the machine side is still the middle. +// +// The foundation bundle writes "127.0.0.1:5432:5432" today, so the shape is not hypothetical. +// Found in review: the first cut split on the first colon, read "127.0.0.1" as the machine port, +// failed to parse it, and silently skipped the mapping — which put the filter back on the +// declared port, the exact fault MachineSide was written to end. +func TestAnAddressedMappingStillNamesThePort(t *testing.T) { + m := Manifest{Module: "store", Resources: []map[string]any{ + {"type": "container", "id": "server", "ports": []any{"127.0.0.1:8080:80"}}, + }} + for _, named := range []int{8080, 80} { + at, mayAssign := m.MachineSide(named) + if mayAssign { + t.Fatalf("%d was reassigned though the manifest published it explicitly", named) + } + if at != 8080 { + t.Fatalf("naming %d gave %d; the machine side of 127.0.0.1:8080:80 is 8080", named, at) + } + } +} diff --git a/go/catalogue/bound_into_files_test.go b/go/catalogue/bound_into_files_test.go new file mode 100644 index 0000000..7abfa3e --- /dev/null +++ b/go/catalogue/bound_into_files_test.go @@ -0,0 +1,265 @@ +package catalogue + +import ( + "fmt" + "strings" + "testing" +) + +// A consumer writes its own connection string (novox/hq 04-ISSUES/023). +// +// Everything needed for one now reaches the file that needs it: the address and port from what the +// provider serves, the name to present from what the mesh decided, the database name from what the +// module itself contributed, and the password through the sealed hole the host fills. +func TestAConsumerCanWriteAConnectionString(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "keycloak", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", + "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n" + + "KC_DB_USERNAME=${bound:postgres-database:as}\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n", + }}, + }}, + Needs: []Needed{{ + Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak", + Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)}, + }}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "keycloak.dbenv") + if file == nil { + t.Fatalf("no file in %v", out) + } + got := file["content"].(string) + + want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" + + "KC_DB_USERNAME=mesh_workstation_keycloak\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n" + if got != want { + t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want) + } + // The password is still a hole. Only the host may fill that one, and only on the machine. + if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" { + t.Errorf("the credential did not travel with the file: %v", file) + } +} + +// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES +// it on, not the port the module declared (novox/hq 04-ISSUES/038). +// +// Issue 018 made the same-node provider announced at all; this is the promise after it — that the +// port a co-located consumer is told is the one actually listening at the address it is handed. The +// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this +// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the +// provider is not published. Cross-node consumers already got this (the plan re-derives the served +// facts with the provider's assignment); the same-node need was settled before the assignment and +// carried the declared port until now. +func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) { + provider := Manifest{ + Module: "postgres", Version: "1", + Provides: FromAnywhere("postgres-database"), + Listens: []Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "postgres", + "ports": []any{"5432"}, + }}, + } + consumer := Manifest{ + Module: "keycloak", Version: "1", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n", + }}, + } + r, err := Resolve(shelf(provider, consumer), + []string{"postgres", "keycloak"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + // The mesh assigned the container's 5432 to 15432 on this machine. + out, err := r.Declaration(Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}}) + if err != nil { + t.Fatal(err) + } + + // Where the provider is actually published: the assigned host port, on the machine at large. + server := fileNamed(out, "postgres.server") + if server == nil { + t.Fatalf("the provider container is not in the declaration: %v", out) + } + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") { + t.Fatalf("the provider was not published on the assigned port: %v", server["ports"]) + } + + // What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the + // module declared, which is closed on that address. + dbenv := fileNamed(out, "keycloak.dbenv") + if dbenv == nil { + t.Fatalf("the consumer's binding file is not in the declaration: %v", out) + } + got := dbenv["content"].(string) + want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n" + if got != want { + t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q", + got, want) + } +} + +// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what +// every connection string in the world would refuse. +func TestAPortIsWrittenAsAPort(t *testing.T) { + if got := plainly(float64(5432)); got != "5432" { + t.Errorf("a port was rendered as %q", got) + } + if got := plainly("postgres"); got != "postgres" { + t.Errorf("a string was rendered as %q", got) + } +} + +// The name the provider will create is the name the consumer is told to present. One derivation, +// so the two agree by construction rather than by two conventions written on the same day. +func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) { + provider, err := Resolve(shelf(Manifest{ + Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"), + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + }), []string{"postgres"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := provider.Declaration(Rendering{Grants: []Grant{{ + Provision: "postgres-database", Consumer: "workstation", From: "keycloak", + Values: map[string]any{"name": "keycloak"}, Sealed: "sealed", + }}}) + if err != nil { + t.Fatal(err) + } + var told string + for _, res := range out { + if res["path"] == "/var/lib/postgres/grants/mesh.json" { + told = res["content"].(string) + } + } + if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) { + t.Errorf("the provider was not told what to call the login: %s", told) + } + if told == "" { + t.Fatal("the provider was told nothing at all") + } + // Which is exactly what the consumer, on its own machine, is told to present. + if ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" { + t.Errorf("got %q", ConsumerIdentity("workstation", "keycloak")) + } +} + +// A binding the module does not have is a typo, refused where it was written. +func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "host=${bound:s3-bucket:at}\n", + }}, + }}, + Needs: []Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module read a binding it does not have") + } + if !strings.Contains(err.Error(), "postgres-database") { + t.Errorf("the refusal does not say what it could have named: %v", err) + } +} + +// A key the provider does not serve is refused too. Written through, the literal placeholder +// becomes a hostname, and the failure names neither the module nor the mesh. +func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "sock=${bound:postgres-database:socket}\n", + }}, + }}, + Needs: []Needed{{ + Name: "postgres-database", From: "anchor", At: "a", For: "keycloak", + Serves: map[string]any{"port": float64(5432)}, + }}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module read a key nothing serves, and the placeholder became a value") + } + if !strings.Contains(err.Error(), `"port"`) { + t.Errorf("the refusal does not say what is served: %v", err) + } +} + +// A name too long to stay distinct is refused before anything is created. +func TestAnIdentityTooLongToBeDistinctIsRefused(t *testing.T) { + if err := CheckIdentity("anchor", "keycloak"); err != nil { + t.Fatalf("an ordinary consumer was refused: %v", err) + } + if err := CheckIdentity(strings.Repeat("n", 40), strings.Repeat("m", 40)); err == nil { + t.Fatal("an identity that providers would shorten was accepted") + } +} + +// Two modules on one node get two identities, which is the whole point of both issues. +func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) { + if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("anchor", "keycloak") { + t.Fatal("two consumers on one machine share an identity") + } + // And one module on two nodes, likewise. + if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("laptop", "gitea") { + t.Fatal("one module on two machines shares an identity") + } +} + +// A machine's resolver configuration must name its resolver by address — it cannot resolve the name +// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing +// machine's private address beside its name, and only when the machine has one. +func TestABindingOffersTheProvidersAddress(t *testing.T) { + consumer := func() Resolution { + return Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "resolv-conf", + Requires: []string{"wildcard-resolution"}, + Resources: []map[string]any{{ + "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", + "content": "nameserver ${bound:wildcard-resolution:address}\n", + }}, + }}, + Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}}, + } + } + out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" { + t.Fatalf("the resolver is not named by its address: %q", got) + } + // A machine with no address yet: refused, never written with a blank where the address belongs. + if _, err := consumer().Declaration(Rendering{}); err == nil { + t.Fatal("a file naming an address the mesh does not have was composed") + } +} diff --git a/go/catalogue/bound_test.go b/go/catalogue/bound_test.go new file mode 100644 index 0000000..d152fff --- /dev/null +++ b/go/catalogue/bound_test.go @@ -0,0 +1,257 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232, +// issue 273). +// +// The incident, exactly: a machine runs its own store and five consumers of it; the mesh's store seat +// is held by the store on another machine. Issue 258's rule — the seat's holder elsewhere answers +// before this machine's own provider — re-bound all five to the holder, each was made a fresh, empty +// database there, and nothing said so. + +var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"} + +func storeMesh() map[string]Manifest { + shelf := map[string]Manifest{ + "store": {Module: "store", Version: "1", + Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-store", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/grants"}}, + "resolver": {Module: "resolver", Version: "1", + Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}}, + "network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + } + for _, c := range incidentConsumers { + shelf[c] = Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}} + } + return shelf +} + +// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a +// resolver and holds both seats; the home server runs its own of each. +func storeWorld() World { + w := World{Offered: map[string][]Provider{ + "postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}}, + }, + "wildcard-resolution": { + {Node: "anchor", At: "anchor.internal", Module: "resolver"}, + {Node: "home", At: "home.internal", Module: "resolver"}, + }, + }} + w.Held = []Held{ + {Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "store"}, + {Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "resolver"}, + } + w.Holdings = w.Held + return w +} + +var home = Node{Name: "home", At: "home.internal"} + +func homeAssigned() []string { + return append([]string{"store", "resolver", "network"}, incidentConsumers...) +} + +func boundFrom(t *testing.T, r Resolution, consumer, provision string) Needed { + t.Helper() + for _, n := range r.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs) + return Needed{} +} + +func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) { + got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" { + t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module) + } + } + // And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258). + if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" { + t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From) + } + if len(got.Kept) != 0 { + t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept) + } +} + +func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]Chosen{} + for _, c := range incidentConsumers { + w.Bound[c] = map[string]Chosen{"postgres-database": {Node: "home", Module: "store"}} + } + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" { + t.Errorf("%s bound to %s", c, n.From) + } + } + if len(got.Kept) != 0 { + t.Errorf("kept %+v", got.Kept) + } +} + +func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) { + w := storeWorld() + w.Pinned = map[string]Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" { + t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module) + } + if len(got.Kept) != 0 { + t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept) + } +} + +// A consumer on a machine with no store of its own, bound to one store, when the seat moves to +// another: the holder would answer, and the binding stays. +func laptopWorld(holder string) World { + w := storeWorld() + w.Held = []Held{{Claim: "mesh-store", Scope: ScopeMesh, Node: holder, Module: "store"}} + w.Holdings = w.Held + return w +} + +var laptop = Node{Name: "laptop", At: "laptop.internal"} + +func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) { + w := laptopWorld("home") + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + n := boundFrom(t, got, "board", "postgres-database") + if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 { + t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves) + } + if len(got.Kept) != 1 { + t.Fatalf("the move was not said: %+v", got.Kept) + } + k := got.Kept[0] + if k.Bound != (Chosen{"anchor", "store"}) || k.Would != (Chosen{"home", "store"}) || k.Consumer != "board" { + t.Fatalf("kept %+v", k) + } + for _, want := range []string{"would move board's postgres-database from anchor/store to home/store", + "its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} { + if !strings.Contains(k.String(), want) { + t.Errorf("%q does not say %q", k.String(), want) + } + } + // Without a record it is a binding being made, and the holder answers it as before. + w.Bound = nil + got, err = Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" { + t.Errorf("a new consumer bound to %s; the seat is held on home", n.From) + } +} + +func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) { + w := laptopWorld("anchor") + w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err == nil { + t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move") + } + for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it", + "pin laptop postgres-database anchor store"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } +} + +func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, home, w) + if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") { + t.Fatalf("got %v", err) + } +} + +// The first pass asks only what a machine offers, and is never refused by a binding. +func TestTheFirstPassKeepsNoBinding(t *testing.T) { + w := storeWorld() + w.Unchecked = true + w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}} + if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil { + t.Fatal(err) + } +} + +func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) { + var o Offer + if err := json.Unmarshal([]byte(`{"name":"wildcard-resolution","scope":"mesh","keeps-consumer-data":false}`), &o); err != nil { + t.Fatal(err) + } + if o.KeepsConsumerData == nil || *o.KeepsConsumerData { + t.Fatalf("read %+v", o) + } + out, err := json.Marshal(o) + if err != nil || !strings.Contains(string(out), `"keeps-consumer-data":false`) { + t.Fatalf("wrote %s, %v", out, err) + } + yes, no := true, false + granting := Manifest{Module: "g", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}, Grants: map[string]string{"p": "/g"}} + if !granting.KeepsConsumerData("p") { + t.Error("a provider granting each consumer a credential keeps what it writes, unsaid") + } + if (Manifest{Module: "r", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}}).KeepsConsumerData("p") { + t.Error("a provider granting nothing keeps nothing, unsaid") + } + granting.Provides[0].KeepsConsumerData = &no + if granting.KeepsConsumerData("p") { + t.Error("what an offer says, it gets") + } + said := Manifest{Module: "s", Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &yes}}} + if !said.KeepsConsumerData("p") || !KeepsConsumerData(map[string]Manifest{"s": said, "r": {Module: "r", + Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &no}}}}, "p") { + t.Error("a name any provider says keeps data keeps data") + } +} + +// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it +// grants a credential. +func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) { + shelf := storeMesh() + no := false + s := shelf["store"] + s.Provides = []Offer{{Name: "postgres-database", Scope: ScopeMesh, KeepsConsumerData: &no}} + shelf["store"] = s + got, err := Resolve(shelf, homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" { + t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From) + } +} diff --git a/go/catalogue/brokered_test.go b/go/catalogue/brokered_test.go new file mode 100644 index 0000000..c379f71 --- /dev/null +++ b/go/catalogue/brokered_test.go @@ -0,0 +1,332 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// Where the answer to a requirement is allowed to live. +// +// A shell, a display server and a private network have to be on the machine that needs them. A +// database does not — it runs somewhere and is reached over the network. Both were written +// `requires`, so both were answered the same way, and the second answer was to install PostgreSQL +// on every machine that runs a web application. + +// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine +// that cannot reach the one answering its requirement is its own case, tested separately. +// reachable is this machine, on the private network. +func reachable() Node { + n := workstation() + n.At = "workstation.internal" + return n +} + +func onNetwork(nodes ...string) map[string][]Provider { + out := make([]Provider, 0, len(nodes)) + for _, n := range nodes { + out = append(out, Provider{Node: n, At: n + ".internal", Module: "postgres"}) + } + return map[string][]Provider{"postgres-database": out} +} + +func brokeredShelf() map[string]Manifest { + return shelf( + Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")}, + Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}, + ) +} + +func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) { + // The fault this whole distinction exists for. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), + World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") { + t.Fatalf("using a database installed one here: %s", have) + } +} + +func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) { + // It is the only part of a node's set that stops working when a *different* machine goes + // away, and it is where a credential will have to be handed back. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), + World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 { + t.Fatalf("got %v", got.Needs) + } + if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" { + t.Fatalf("got %v", got.Needs[0]) + } + if got.Needs[0].For != "meshboard" { + t.Fatalf("it does not say what wanted it: %v", got.Needs[0]) + } +} + +func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) { + // Refused rather than installed here. Choosing a machine to put a database on is a decision + // with consequences, and nothing resolving a web application should make it silently. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), World{}) + if err == nil { + t.Fatal("a database was found in a mesh that has none") + } + if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") { + t.Fatalf("the refusal does not say what to do: %v", err) + } +} + +func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) { + // Same rule as everywhere else. Picking one would be a guess about which database a person + // meant, and the wrong guess is somebody's data in the wrong place. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), + World{Offered: onNetwork("anchor", "archive")}) + if err == nil { + t.Fatal("one of two databases was picked silently") + } + for _, want := range []string{"anchor", "archive", "pin"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestSayingWhichOneSettlesIt(t *testing.T) { + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), + World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the choice was not taken: %v", got.Needs) + } +} + +func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) { + // Rather than falling back to one that does. A fallback would quietly move somebody's data to + // a machine they did not choose, which is the whole reason the question is asked. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), + World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("a machine was silently given a different database from the one chosen") + } + if !strings.Contains(err.Error(), "somewhere-else") { + t.Fatalf("the refusal does not say what was chosen: %v", err) + } +} + +func TestOneProviderDoesNotOverruleAChoice(t *testing.T) { + // A single answer is normally taken silently. Not when somebody said they wanted a different + // one -- that is the mesh overruling a person, which it does nowhere else. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), + World{ + Offered: onNetwork("anchor"), + Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("the only database was used although another was chosen") + } + if !strings.Contains(err.Error(), "only anchor/postgres provides it") { + t.Fatalf("the refusal does not say what is available: %v", err) + } +} + +func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) { + // If one module says a database is local and another says it is anywhere, the same + // requirement means two things depending on which one happens to answer it. + _, err := Resolve(shelf( + Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")}, + Manifest{Module: "sqlite", Version: "1", Provides: Offers("postgres-database")}, + Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}, + ), []string{"meshboard"}, workstation(), World{}) + if err == nil { + t.Fatal("a catalogue that disagrees about where a database lives was accepted") + } + if !strings.Contains(err.Error(), "two things") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) { + // The change must not have made everything brokered. A shell is still installed here. + got, err := Resolve(shelf( + Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")}, + Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}, + ), []string{"tools"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") { + t.Fatalf("a shell was not installed on the machine that needs one: %s", have) + } +} + +func TestTheShortFormStillMeansHere(t *testing.T) { + // `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest + // silently changes meaning. + m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.Provides) != 1 || m.Provides[0].At() != ScopeNode { + t.Fatalf("a plain name is no longer node-scoped: %v", m.Provides) + } +} + +func TestASiteScopedProvisionIsRefused(t *testing.T) { + // Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a + // provision, because nothing knows how to reach "the one at my site". + _, err := ParseManifest([]byte( + `{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`)) + if err == nil { + t.Fatal("a site-scoped provision was accepted") + } + if !strings.Contains(err.Error(), "site") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +// Being told, which is the difference between knowing and being able to. + +func boundShelf() map[string]Manifest { + return shelf( + Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"), + Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, + Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}, + Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}}, + ) +} + +func binding(t *testing.T, out []map[string]any) map[string]any { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/meshboard/database.json" { + continue + } + var parsed map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatalf("what the app is told is not readable: %v", err) + } + return parsed + } + t.Fatalf("the app was told nothing: %v", out) + return nil +} + +func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) { + // Knowing it needs the anchor's database is useless to the program that needs it unless the + // program is told. This is the whole point of the field. + got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), + World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal", + Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}}) + if err != nil { + t.Fatal(err) + } + told := binding(t, mustDeclare(t, got)) + if told["from"] != "anchor" || told["at"] != "anchor.internal" { + t.Fatalf("it was not told where: %v", told) + } + serves, _ := told["serves"].(map[string]any) + if serves["port"] != float64(5432) || serves["driver"] != "postgres" { + t.Fatalf("it was not told how: %v", serves) + } +} + +func TestItSaysWhereTheCredentialIsInstead(t *testing.T) { + // A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring + // this up must not spend an afternoon looking for the password field. + // + // It used to say only that the mesh had no way to issue one, which stopped being true when + // 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it + // sends the reader somewhere there is nothing to find. + got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), + World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + told := binding(t, mustDeclare(t, got)) + note, _ := told["generated"].(string) + if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") { + t.Fatalf("the file does not say where the credential is instead: %v", note) + } + for key := range told { + if strings.Contains(key, "password") || strings.Contains(key, "secret") { + t.Fatalf("something that looks like a credential appeared: %q", key) + } + } +} + +func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) { + // An app on one machine and a database on another that share no private network is a mesh + // that reports itself configured and does not work. Said here rather than discovered as a + // connection timing out. + _, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), // not on the network + World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database it has no path to") + } + if !strings.Contains(err.Error(), "private network") { + t.Fatalf("the refusal does not say what is wrong: %v", err) + } + if !strings.Contains(err.Error(), meshNetwork) { + t.Fatalf("the refusal does not say what to assign: %v", err) + } +} + +func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) { + // Both directions, because the failure is identical from either end and the remedy differs. + _, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), + World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database that is not on the private network") + } + if !strings.Contains(err.Error(), "anchor") { + t.Fatalf("the refusal does not name the unreachable end: %v", err) + } +} + +func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) { + // A file saying "it is on this node" is a fact nobody needs and one more thing to keep true. + got, err := Resolve(shelf( + Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")}, + Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}, + Binds: map[string]string{"shell": "/etc/tools/shell.json"}}, + ), []string{"tools"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + for _, r := range mustDeclare(t, got) { + if r["path"] == "/etc/tools/shell.json" { + t.Fatalf("a binding was written for something on this machine: %v", r) + } + } +} + +func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"app","version":"1", + "binds":{"postgres-database":"/etc/app/db.json"}}`)) + if err == nil { + t.Fatal("a module was told about something it never asked for") + } + if !strings.Contains(err.Error(), "does not require") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"app","version":"1", + "serves":{"postgres-database":{"port":5432}}}`)) + if err == nil { + t.Fatal("a module served something it does not provide") + } + if !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("unhelpful refusal: %v", err) + } +} diff --git a/go/catalogue/build_system_test.go b/go/catalogue/build_system_test.go new file mode 100644 index 0000000..e1a97dd --- /dev/null +++ b/go/catalogue/build_system_test.go @@ -0,0 +1,82 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// bundleFor is a manifest whose one artifact is a bundle in the given language and system. +func bundleFor(language, system string) Manifest { + return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{ + {Name: "binary", Kind: ArtifactBundle, Language: language, System: system}, + }}} +} + +func problemsOf(t *testing.T, m Manifest) string { + t.Helper() + return strings.Join(m.Build.problems(m.Module), "\n") +} + +// **A language that compiles to a binary must say which system.** +// +// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it +// was not built for. An artifact that says nothing would be compiled for whatever the build machine +// happened to be — which reads as portable and is not, and is the fault this check exists for. +func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) { + got := problemsOf(t, bundleFor("go", "")) + if !strings.Contains(got, "says no system") { + t.Fatalf("a compiled bundle with no system was accepted:\n%s", got) + } + // And the refusal names what it could have said, so a reader is one edit from right. + for _, system := range []string{"alpine", "android", "arch"} { + if !strings.Contains(got, system) { + t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got) + } + } +} + +func TestABinaryThatNamesASystemIsAccepted(t *testing.T) { + if got := problemsOf(t, bundleFor("go", "arch")); got != "" { + t.Fatalf("a compiled bundle naming a system was refused:\n%s", got) + } +} + +// A system the mesh does not build for is refused where it is written. These are the host's own +// names, not a compiler's: the difference between two of them is a C library rather than a kernel, +// so a value that looks like an operating system to a toolchain is still wrong here. +func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) { + for _, wrong := range []string{"linux", "debian", "darwin"} { + got := problemsOf(t, bundleFor("go", wrong)) + if !strings.Contains(got, "and a system is") { + t.Fatalf("%q was accepted as a system:\n%s", wrong, got) + } + } +} + +// **And a language that runs anywhere must not name one.** A system that decides nothing reads as +// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045). +func TestAPortableBundleMayNotNameASystem(t *testing.T) { + got := problemsOf(t, bundleFor("typescript", "arch")) + if !strings.Contains(got, "runs anywhere") { + t.Fatalf("a portable bundle was allowed to name a system:\n%s", got) + } +} + +func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) { + if got := problemsOf(t, bundleFor("typescript", "")); got != "" { + t.Fatalf("an ordinary bundle was refused:\n%s", got) + } +} + +// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the +// reason the target is the artifact's rather than the recipe's. +func TestOneArtifactPerSystemIsAccepted(t *testing.T) { + m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{ + {Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"}, + {Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"}, + {Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"}, + }}} + if got := problemsOf(t, m); got != "" { + t.Fatalf("one artifact per system was refused:\n%s", got) + } +} diff --git a/go/catalogue/build_test.go b/go/catalogue/build_test.go new file mode 100644 index 0000000..c17c0eb --- /dev/null +++ b/go/catalogue/build_test.go @@ -0,0 +1,203 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The manifest in a repository and the manifest the mesh holds are two documents. +// +// A digest is not knowable until something is built, so a repository carrying one would be a +// repository whose file is wrong the moment anybody edits anything — and the mesh would be pinning +// a value nobody could have checked. + +func buildable() Manifest { + return Manifest{ + Module: "meshboard", Version: "1", + Build: &Build{Artifacts: []Artifact{ + {Name: "server", Kind: ArtifactImage, From: "Dockerfile"}, + {Name: "theme", Kind: ArtifactArchive, From: "files"}, + }}, + Resources: []map[string]any{ + {"id": "svc", "type": "container", "name": "meshboard", "artifact": "server"}, + {"id": "look", "type": "archive", "path": "/opt/meshboard", "artifact": "theme"}, + {"id": "dir", "type": "directory", "path": "/opt/meshboard"}, + }, + } +} + +func wasBuilt() []Built { + return []Built{ + {Name: "server", Kind: ArtifactImage, + Reference: "registry.invalid/meshboard@sha256:" + strings.Repeat("a", 64)}, + {Name: "theme", Kind: ArtifactArchive, + Reference: "https://store.invalid/theme.tar.gz", + Digest: "sha256:" + strings.Repeat("b", 64)}, + } +} + +func TestAResourceNamingAnArtifactBecomesOneNamingTheThing(t *testing.T) { + got, err := buildable().Resolve(wasBuilt()) + if err != nil { + t.Fatal(err) + } + for _, r := range got.Resources { + if r["artifact"] != nil { + // A build-time word the host has never heard of. Leaving it would be a field the + // strict decoder refuses on the machine, at the worst moment. + t.Fatalf("%v still names an artifact: %v", r["id"], r) + } + } + if got.Resources[0]["image"] != "registry.invalid/meshboard@sha256:"+strings.Repeat("a", 64) { + t.Fatalf("the image was not filled in: %v", got.Resources[0]) + } + if got.Resources[1]["source"] != "https://store.invalid/theme.tar.gz" || + got.Resources[1]["digest"] != "sha256:"+strings.Repeat("b", 64) { + t.Fatalf("the archive was not filled in: %v", got.Resources[1]) + } + // And a resource that names nothing is untouched. + if got.Resources[2]["path"] != "/opt/meshboard" || len(got.Resources[2]) != 3 { + t.Fatalf("an ordinary resource was changed: %v", got.Resources[2]) + } + // The built manifest carries no build section: it is the derived document, and something + // holding both would invite somebody to build from it again. + if got.Build != nil { + t.Fatal("the built manifest still says how to build itself") + } +} + +func TestAnArtifactDeclaredAndNotBuiltIsRefused(t *testing.T) { + // A build that did not do what the manifest asked. Said here rather than by a machine later + // reporting an empty image. + _, err := buildable().Resolve(wasBuilt()[:1]) + if err == nil { + t.Fatal("a manifest resolved with an artifact missing") + } + if !strings.Contains(err.Error(), "theme") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } + // And it must blame the build rather than the resource. A resource asking for something that + // does not exist is a manifest fault; a build not producing what the manifest declared is a + // build fault, and telling somebody to fix the wrong one costs an afternoon. + if !strings.Contains(err.Error(), "the build did not produce") { + t.Fatalf("the refusal blames the wrong thing: %v", err) + } +} + +func TestAResourceNamingSomethingTheModuleDoesNotBuildIsRefused(t *testing.T) { + m := buildable() + m.Resources = append(m.Resources, + map[string]any{"id": "other", "type": "archive", "path": "/x", "artifact": "nothing"}) + if _, err := m.Resolve(wasBuilt()); err == nil { + t.Fatal("a resource naming an artifact nobody builds was accepted") + } +} + +func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) { + // Most of what a person installs is configuration. Requiring an empty build section would be + // a field that exists to be left blank. + m := Manifest{Module: "shell", Version: "1", Resources: []map[string]any{ + {"id": "rc", "type": "file", "path": "/etc/zsh/zshrc", "content": "setopt"}, + }} + got, err := m.Resolve(nil) + if err != nil { + t.Fatal(err) + } + if len(got.Resources) != 1 { + t.Fatalf("got %v", got.Resources) + } +} + +func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"image","from":"Dockerfile"}, + {"name":"x","kind":"archive","from":"files"}]}}`)) + if err == nil { + t.Fatal("two artifacts with one name were accepted") + } + if !strings.Contains(err.Error(), "could mean either") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) { + // A build reads its own repository and nothing else. A path leaving it would make what gets + // built depend on whatever happens to be on the machine doing the building. + for _, from := range []string{"/etc/passwd", "../elsewhere"} { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"archive","from":"` + from + `"}]}}`)) + if err == nil { + t.Fatalf("%q was accepted as a build input", from) + } + if !strings.Contains(err.Error(), "outside its own repository") { + t.Fatalf("refused for the wrong reason: %v", err) + } + } +} + +func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"binary","from":"main.go"}]}}`)) + if err == nil { + t.Fatal("an artifact of an unknown kind was accepted") + } +} + +func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { + // The SDK's shape: a package built from the module's own directory, naming a language. + m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk", + "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, + "resources":[]}`)) + if err != nil { + t.Fatalf("the SDK's package manifest did not parse: %v", err) + } + if m.Build.Artifacts[0].Kind != ArtifactPackage { + t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind) + } + + // A package that names what it is built from is refused, exactly as a bundle is: it is built + // from the module's own directory. + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil { + t.Fatal("a package naming a source was accepted") + } + // A package with no language cannot choose a toolchain. + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"lib","kind":"package"}]}}`)); err == nil { + t.Fatal("a package with no language was accepted") + } +} + +func TestAResourceNamingAPackageIsRefused(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a", + "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, + "resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + _, err = m.Resolve([]Built{{Name: "lib", Kind: ArtifactPackage, Reference: "@novox/a@1.0.0"}}) + if err == nil { + t.Fatal("a resource backed by a package was accepted; a package is not a resource") + } +} + +// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in, +// and only an image says one. +func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ + {"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`)) + if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" { + t.Fatalf("an image naming its package was refused or lost it: %v", err) + } + for _, c := range []struct{ artifact, says string }{ + {`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"}, + {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"}, + {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"}, + {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"}, + } { + _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says) + } + } +} diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go new file mode 100644 index 0000000..871817d --- /dev/null +++ b/go/catalogue/busy_test.go @@ -0,0 +1,94 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-host/validate" +) + +// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its +// shape near its author, and it is sent only to a node-engine that reads it, in words the engine's own +// validator accepts. + +func busyManifest(t *testing.T, on string, busy any) []byte { + t.Helper() + extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent", + "image": "registry.example/app" + healthDigest} + var m map[string]any + if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil { + t.Fatal(err) + } + for _, r := range m["resources"].([]any) { + if r := r.(map[string]any); r["id"] == on { + r[BusyField] = busy + } + } + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestABusyIsHeldToItsShape(t *testing.T) { + if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil { + t.Fatalf("a well-formed busy was refused: %v", err) + } + if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil { + t.Fatalf("a busy with its timeout was refused: %v", err) + } + for name, c := range map[string]struct { + on string + busy any + says string + }{ + "text": {"agent", "mesh-builder busy", "it is an object"}, + "no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"}, + "two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"}, + "a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"}, + "an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`}, + "on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"}, + "on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"}, + } { + _, err := ParseManifest(busyManifest(t, c.on, c.busy)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err) + } + } +} + +func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { + m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) + if err != nil { + t.Fatal(err) + } + compose := func(with Rendering) []map[string]any { + out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + return out + } + older := compose(Rendering{}) + if b, sent := older[indexOfID(older, "app.agent")][BusyField]; sent { + t.Fatalf("busy was sent to an engine that does not read it: %v", b) + } + newer := compose(Rendering{ReadsBusy: true}) + got, _ := newer[indexOfID(newer, "app.agent")][BusyField].(map[string]any) + if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" { + t.Fatalf("busy composed as %v", got) + } + // And the engine's own validator takes the container with it. + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems) + } + if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has { + t.Error("composing wrote the timeout into the catalogue's own manifest") + } +} diff --git a/go/catalogue/co_located_test.go b/go/catalogue/co_located_test.go new file mode 100644 index 0000000..340a813 --- /dev/null +++ b/go/catalogue/co_located_test.go @@ -0,0 +1,315 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A provider and its consumer on ONE machine, which is what ADR 0066's anchor is. +// +// **Every fault in this file is the same shape: the same-node path diverging from the cross-node +// one.** A provider on another machine is walked by the control plane — its served facts are +// re-derived from its manifest with that machine's port assignments and settled with that node's +// settings layers — and only then handed to the consumer. A provider on the consumer's OWN machine +// never passes through that walk, so every step of it had to be repeated here, and each step that +// was not is a promise the co-located arrangement quietly breaks. +// +// 04-ISSUES/038 was the first of them (the port). These are the rest. + +// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key +// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339). +const servedRoot = `-----BEGIN CERTIFICATE----- +MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG +A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2 +WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO +cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN +p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB +/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k +IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ== +-----END CERTIFICATE----- +` + +// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a +// consumer must know is the directory URL and the root to trust. **The root is not knowable when +// the module is written** — it exists only once the CA has been initialised — so the manifest +// declares the key and the operator supplies the value as a setting, which is exactly the shape the +// cross-node path settles and the same-node path did not. +func stepCA() Manifest { + return Manifest{ + Module: "step-ca", Version: "1", + Provides: FromAnywhere("acme-ca"), + Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}}, + Serves: map[string]map[string]any{"acme-ca": { + "directory": "https://anchor.internal/acme/acme/directory", + // Declared empty: a manifest is the same on every mesh, and this mesh's root is not. + "root": "", + }}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"}, + }}, + } +} + +// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as +// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer +// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole). +func stepCAWithItsRoot() Manifest { + m := stepCA() + m.Serves["acme-ca"]["root"] = "${setting:root}" + return m +} + +// routeProxy issues from that authority, so it must be told the root to verify it with. +func routeProxy() Manifest { + return Manifest{ + Module: "route-proxy", Version: "1", + Requires: []string{"acme-ca"}, + Provides: FromAnywhere("route"), + Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"}, + Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"}, + Resources: []map[string]any{{ + "id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644", + "content": "${bound:acme-ca:root}", + }}, + } +} + +// A co-located provider's served VALUES reach its consumer, not just its served keys. +// +// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings +// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the +// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's +// here() both read the manifest and stop there. So a served value the operator supplied — the one +// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the +// manifest's empty default. +// +// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the +// system trust store, could not verify the internal authority, and no certificate was ever issued. +func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + + // What the operator set on the provider, on this node — the CA's root, which only exists once + // the CA has been initialised. + out, err := r.Declaration(Rendering{Settings: SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil { + t.Fatalf("the consumer was given no bundle at all: %v", out) + } + if got := bundle["content"]; got != servedRoot { + t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q", + got, servedRoot) + } +} + +// And the binding file says the same thing, for a consumer that reads the binding rather than a +// substituted placeholder. The two are one fact and must not be able to disagree. +func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{ + Settings: SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }, + // And the machine moved the provider's port while it was at it, so both halves of the + // cross-node derivation are exercised at once. + Ports: map[string]map[int]int{"step-ca": {9000: 19000}}, + }) + if err != nil { + t.Fatal(err) + } + + binding := fileNamed(out, "route-proxy.bound-acme-ca") + if binding == nil { + t.Fatalf("the consumer was given no binding at all: %v", out) + } + var said struct { + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil { + t.Fatal(err) + } + if said.Serves["root"] != servedRoot { + t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"]) + } + if port, _ := asPort(said.Serves["port"]); port != 19000 { + t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"]) + } +} + +// A provider PULLED IN rather than assigned is settled the same way. +// +// The resolver's same-node need is built during the walk, from whichever modules had been chosen by +// the time the requirement came up — so which path a co-located binding took depended on the order +// a person happened to assign things in. Settling after the closure is known removes that: both +// orders now produce the same file. +func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) { + for _, assigned := range [][]string{ + {"step-ca", "route-proxy"}, + {"route-proxy", "step-ca"}, + } { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{Settings: SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil || bundle["content"] != servedRoot { + t.Errorf("assigned %v: the consumer was not told the root", assigned) + } + } +} + +// A same-node contribution names the port the MACHINE publishes, not the one the module declared. +// +// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other +// direction: what a workload TELLS the provider about itself. gitea declares a bare container port +// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so +// the proxy beside it dialled a port nothing listens on and answered 502 for every request. +// +// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and +// using the provider's map would move it to wherever the proxy happens to be published. +func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) { + gitea := Manifest{ + Module: "gitea", Version: "1", + Listens: []Listening{{Port: 3000, Protocol: "tcp", From: FromMesh}}, + Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"}, + }}, + } + r, err := Resolve(shelf(gitea, routeProxy(), stepCA()), + []string{"gitea", "route-proxy", "step-ca"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{ + // The machine put gitea's 3000 on 20000, and published it that way. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + }) + if err != nil { + t.Fatal(err) + } + + // Where the workload is actually published. + server := fileNamed(out, "gitea.server") + if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" { + t.Fatalf("the workload was not published on the assigned port: %v", server["ports"]) + } + + // What the proxy beside it was told to dial: the SAME port. + routes := fileNamed(out, "route-proxy.received-route") + if routes == nil { + t.Fatalf("the proxy was given no routes file at all: %v", out) + } + var given struct { + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + port, ok := asPort(given.Given[0].Values["port"]) + if !ok || port != 20000 { + t.Errorf("the proxy was told to dial a port nothing listens on: %v", + given.Given[0].Values["port"]) + } +} + +// A contribution from ANOTHER machine is left exactly as it was. +// +// Its port belongs to that machine's assignment, which this node's map knows nothing about — +// applying this node's map to it would move a remote workload's port to wherever a local module of +// the same name happens to be published, which is worse than the fault being fixed. +func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) { + r, err := Resolve(shelf(routeProxy(), stepCA()), + []string{"route-proxy", "step-ca"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{ + // A same-named module on this machine, moved. The grant below is a laptop's, and must not + // be dragged along with it. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + Grants: []Grant{{ + Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal", + Values: map[string]any{"name": "git.example", "port": 3000}, + }}, + }) + if err != nil { + t.Fatal(err) + } + routes := fileNamed(out, "route-proxy.received-route") + var given struct { + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 { + t.Errorf("another machine's contribution was rewritten with this machine's ports: %v", + given.Given[0].Values["port"]) + } +} + +// The cross-node half of the same fault: a grant assembled on the providing machine carries the +// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The +// declaration layer cannot do it — those assignments are not this machine's, which is the line the +// test above holds — so the grant layer is handed them and does it there. +func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) { + published := map[int]int{3000: 20000} + + got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published) + if port, _ := asPort(got["port"]); port != 20000 { + t.Errorf("the proxy would dial a port that machine never published: %v", got["port"]) + } + if got["name"] != "git.example.tld" { + t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got) + } + + // Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no + // assignment, and must come back exactly as it was written. + again := AtPublishedPort(got, "forge", published) + if port, _ := asPort(again["port"]); port != 20000 { + t.Errorf("applying it twice moved the port again: %v", again["port"]) + } + pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published) + if port, _ := asPort(pinned["port"]); port != 9070 { + t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"]) + } +} + +func asStrings(v any) []string { + listed, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(listed)) + for _, one := range listed { + out = append(out, strings.TrimSpace(plainly(one))) + } + return out +} diff --git a/go/catalogue/collections_l2_test.go b/go/catalogue/collections_l2_test.go new file mode 100644 index 0000000..c16c4a0 --- /dev/null +++ b/go/catalogue/collections_l2_test.go @@ -0,0 +1,60 @@ +package catalogue + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The collectors design 57 L2 moved (issue 681), composed with nothing handed: zsh's rc, triggerhappy's triggers and +// power's after-wake moment, from the catalogue's real templates and its moved contributors' real items, are byte +// for byte what the controller composed from the old forms before the move (testdata/l2-moved: templates/ and items/ +// copied from mesh-catalog at the move, want/ composed at the feature branch before it). + +func l2Moved(t *testing.T, parts ...string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) { + // Each moved contributor, its items read back from its templates archive as the mesh holds them. + items := map[string][]string{ + "asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"}, + "claude-code": {"shell:zsh:normal"}, + "mesh-cli": {"shell:zsh:normal"}, + "powerlevel10k": {"shell:zsh:normal"}, + "zsh-autosuggestions": {"shell:zsh:normal"}, + "zsh-syntax-highlighting": {"shell:zsh:last"}, + } + var modules []Manifest + for _, module := range sortedKeys(items) { + m := Manifest{Module: module, Moved: true} + for _, collection := range items[module] { + m.Items = append(m.Items, Item{Collection: collection, + Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))}) + } + modules = append(modules, m) + } + for _, c := range []struct{ module, seat string }{{"zsh", LoginShellSeat}, {"triggerhappy", HotkeysSeat}, + {"power", PowerSeat}} { + holder := Manifest{Module: c.module, Moved: true, Claims: []Claim{{Name: c.seat}}} + resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module, + "content": l2Moved(t, "templates", c.module)} + var unplaced []string + if err := contributionsInto(resource, holder, append([]Manifest{holder}, modules...), + map[string]string{"name": "g14"}, Rendering{}, map[string]bool{}, &unplaced); err != nil { + t.Fatalf("%s: %v", c.module, err) + } + if got, want := resource["content"], l2Moved(t, "want", c.module); got != want { + t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want) + } + if len(unplaced) != 0 { + t.Errorf("%s: unplaced %v", c.module, unplaced) + } + } +} diff --git a/go/catalogue/collections_test.go b/go/catalogue/collections_test.go new file mode 100644 index 0000000..c9aaf1b --- /dev/null +++ b/go/catalogue/collections_test.go @@ -0,0 +1,179 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// A collection is declared and filled by the client (novox/hq ADR 0349 §13, design 57 L1, issue 684): a collector +// names a collection it collects, a contributor an item from a template of its own, and a handed composition plans +// both for the node's provisioner client and renders neither. The three old namespaces read as collections of their +// own name. Each file is filled one way: a piece of the other form on the machine is said as not placed. + +const ( + collectorZsh = `{"module":"zsh","version":"1","moved":true, + "claims":[{"name":"node-login-shell","scope":"node"}], + "collections":[{"name":"widgets","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}", + "mode":"0644","template":"files/zshrc"}]}` + zshrcText = "# zsh\n${shell:zsh:normal}${collected:widgets}" + contributorText = "source ${machine:account-home}/.p10k.zsh\n" + contributorP10k = `{"module":"prompt","version":"1","moved":true, + "items":[{"collection":"shell:zsh:normal","template":"files/zsh"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + oldFormAliases = `{"module":"aliases","version":"1", + "shell":[{"for":"zsh","slot":"normal","code":"alias ll='ls -l'\n"}]}` +) + +func builtWith(module string, texts map[string]string) []Built { + return []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64), + Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}} +} + +func collectionsOn(t *testing.T) Resolution { + t.Helper() + zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText})) + prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText})) + aliases, err := ParseManifest([]byte(oldFormAliases)) + if err != nil { + t.Fatal(err) + } + r, err := Resolve(shelf(zsh, prompt, aliases), []string{"zsh", "prompt", "aliases"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +func TestAContributorsBuildKeepsItsItemsTemplateAndText(t *testing.T) { + m := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText})) + if m.Handed == nil || !reflect.DeepEqual(m.Handed.Items, map[string]string{"shell:zsh:normal": "zsh"}) { + t.Fatalf("the build kept %+v", m.Handed) + } + if !reflect.DeepEqual(m.Items, []Item{{Collection: "shell:zsh:normal", Content: contributorText}}) { + t.Fatalf("the held items are %+v: the template's text, read back", m.Items) + } +} + +func TestAHandedCollectorAndContributorArePlannedAndNeitherIsRendered(t *testing.T) { + c, got := composeWith(t, collectionsOn(t), handingTo("store:5000")) + if strings.Contains(got, ".p10k.zsh") || strings.Contains(got, "alias ll") { + t.Fatalf("the controller rendered a contribution into a handed collector: %s", got) + } + zsh, prompt := c.Plans["zsh"], c.Plans["prompt"] + wantCollections := []PlanCollection{{Name: "shell:zsh:normal", Comment: "#"}, {Name: "widgets", Comment: "#"}} + if !reflect.DeepEqual(zsh.Collections, wantCollections) || len(zsh.Items) != 0 { + t.Fatalf("zsh's plan collects %+v and contributes %+v", zsh.Collections, zsh.Items) + } + if !reflect.DeepEqual(prompt.Items, []PlanItem{{Collection: "shell:zsh:normal", Template: "zsh"}}) || + len(prompt.Files) != 0 || prompt.Placement["machine:account-home"] != "/home/op" { + t.Fatalf("prompt's plan is %+v: its item, by template, and the placement its template names", prompt) + } + // The old form on the same machine is not placed, and said so. + if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "aliases's shell:zsh:normal is not placed") { + t.Fatalf("unplaced: %v", c.Unplaced) + } +} + +// A collector filled the old way holds the old forms and each moved contributor's item, rendered from the text the +// mesh holds as its old form was (design 57 L2, issue 681), so the file loses no line where nothing is handed; a +// collection of the new form stays empty. +func TestACollectorFilledTheOldWayHoldsTheOldFormsAndEachHeldItem(t *testing.T) { + c, got := composeWith(t, collectionsOn(t), Rendering{ArtifactStore: "store:5000"}) + if c.Plans != nil { + t.Fatalf("handed on a machine that cannot take it: %v", c.Plans) + } + if !strings.Contains(got, `"content":"# zsh\n# aliases\nalias ll='ls -l'\n# prompt\nsource /home/op/.p10k.zsh\n"`) { + t.Fatalf("the old way does not hold the old forms and the held item in module order: %s", got) + } + if len(c.Unplaced) != 0 { + t.Fatalf("unplaced: %v", c.Unplaced) + } +} + +// An item the controller cannot render the old way (it names a value but a machine fact) is said as not placed. +func TestAHeldItemNamingMoreThanAFactIsSaidNotPlacedTheOldWay(t *testing.T) { + zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText})) + if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal", + Content: "export THEME=${config:theme}\n"}}}, "shell:zsh:normal", map[string]string{}); ok { + t.Fatal("an item naming a config value was rendered by the controller") + } + if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal", + Content: "cd ${machine:account-home}\n"}}}, "shell:zsh:normal", map[string]string{}); ok { + t.Fatal("an item naming a fact this machine lacks was rendered") + } + if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "widgets", Content: "x\n"}}}, + "widgets", nil); ok { + t.Fatal("an item of a collection of the new form was rendered the old way") + } + left := itemsLeftOut([]Manifest{zsh, {Module: "p", Items: []Item{{Collection: "shell:zsh:normal", + Content: "export THEME=${config:theme}\n"}}}}, zsh, "shell:zsh:normal", map[string]string{}) + if len(left) != 1 || !strings.Contains(left[0], "p's item of shell:zsh:normal is not placed") { + t.Fatalf("left out: %v", left) + } +} + +// Each old namespace L2 moves renders a moved contributor's item exactly as its old form did, where the collector is +// not handed: the same contributor written both ways composes the same bytes (design 57 L2, issue 681). +func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) { + code := "# two lines\nalias a=b" + asOld := Manifest{Module: "laptop", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: code}}, + Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"}}} + asItem := Manifest{Module: "laptop", Moved: true, Items: []Item{{Collection: "shell:zsh:normal", Content: code}, + {Collection: "contribution:node-hotkeys:trigger", Content: "KEY_F21 1 touchpad\n"}}} + other := Manifest{Module: "zz-other", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "x=1\n"}}, + Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute\n"}}} + first := Manifest{Module: "aa-first", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "y=2\n"}}} + for _, mods := range [][]Manifest{{other, first}, {}} { + was := append(append([]Manifest(nil), mods...), asOld) + is := append(append([]Manifest(nil), mods...), asItem) + if a, b := shellCodeOn(was, "zsh", "normal", nil), shellCodeOn(is, "zsh", "normal", nil); a != b || a == "" { + t.Errorf("shell: the old form made %q, the item %q", a, b) + } + a, _, errA := seatContributions(was, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil) + b, left, errB := seatContributions(is, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil) + if errA != nil || errB != nil || a != b || a == "" || len(left) != 0 { + t.Errorf("hotkeys: the old form made %q, the item %q (%v %v %v)", a, b, errA, errB, left) + } + } +} + +func TestCollectionsAndItemsAreJudged(t *testing.T) { + for name, c := range map[string]struct{ raw, says string }{ + "an item of a module not moved": {`{"module":"m","version":"1", + "items":[{"collection":"shell:zsh:normal","template":"files/x"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"}, + "two items to one collection": {`{"module":"m","version":"1","moved":true, + "items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"}, + "an item to no slot": {`{"module":"m","version":"1","moved":true, + "items":[{"collection":"shell:zsh:middle","template":"files/x"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"}, + "an item outside the templates": {`{"module":"m","version":"1","moved":true, + "items":[{"collection":"a","template":"x"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"}, + "an item naming a secret": {`{"module":"m","version":"1", + "items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"}, + "a collection not declared": {`{"module":"m","version":"1", + "resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"}, + "an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}], + "resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"}, + "a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}], + "resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"}, + "a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}], + "resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`, + "only in a file's content"}, + } { + _, err := ParseManifest([]byte(c.raw)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, want it to say %q", name, err, c.says) + } + } + if _, err := ParseManifest([]byte(collectorZsh)); err != nil { + t.Errorf("a collector: %v", err) + } +} diff --git a/go/catalogue/computed_test.go b/go/catalogue/computed_test.go new file mode 100644 index 0000000..ccdd198 --- /dev/null +++ b/go/catalogue/computed_test.go @@ -0,0 +1,205 @@ +package catalogue + +import ( + "fmt" + "strings" + "testing" +) + +// A module whose files cannot be written in advance. +// +// The mesh's private network is the case: a machine's peer list is derived from every other +// machine, so it differs on each one and changes when any of them changes. What matters in these +// tests is not that it works, but that being computed changes *nothing else* about being a +// module — it is assigned, resolved, settled and absent when nobody asked for it. + +type fake struct { + on map[string]bool + asked []string + err error +} + +func (f *fake) Resources(node string) ([]map[string]any, bool, error) { + f.asked = append(f.asked, node) + if f.err != nil { + return nil, false, f.err + } + if !f.on[node] { + return nil, false, nil + } + return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf", + "merge": MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil +} + +func computedShelf() map[string]Manifest { + return shelf(Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: Offers("private-network")}) +} + +func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) { + // The generator gets a node name, not a plan. Everything it needs is the whole mesh, which + // it was built with — this is the one thing a node could never work out for itself. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(gen.asked) != 1 || gen.asked[0] != "workstation" { + t.Fatalf("asked about %v, wanted workstation once", gen.asked) + } + if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) { + t.Fatalf("got %v", out) + } +} + +func TestAMachineNobodyGaveItGetsNothing(t *testing.T) { + // The whole point of making the network a module. Before this, every machine with an address + // was on the private network and there was no way to say one should stay off. + got, err := Resolve(computedShelf(), nil, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out)) + } + if len(gen.asked) != 0 { + t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked) + } +} + +func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) { + // A machine given the network module before it has a place on it. Brief and ordinary — the + // answer is "nothing yet", and treating it as an error would make an ordering a fault. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{}) + gen := &fake{on: map[string]bool{}} + out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}}) + if err != nil { + t.Fatalf("refused a node that is not on the network yet: %v", err) + } + if len(out) != 0 { + t.Fatalf("got %v", out) + } +} + +func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) { + // Sending a machine a module with no files would look like it worked. Named in the message, + // because the only fix is a control plane that has it. + got, _ := Resolve(shelf(Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), World{}) + + _, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": &fake{}}}) + if err == nil { + t.Fatal("a module computed by nothing was accepted") + } + if !strings.Contains(err.Error(), "the-weather") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) { + // Otherwise nobody could say where a given file on a machine came from. + _, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1", + "computed":"mesh-network", + "resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`)) + if err == nil { + t.Fatal("a module that both ships files and has them computed was accepted") + } + if !strings.Contains(err.Error(), "one or the other") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestSettingsApplyToAComputedModuleToo(t *testing.T) { + // Being computed is about where the files come from, not about whether they are configurable. + // A line drawn there would be arbitrary and nobody could predict it. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{}) + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := got.Declaration(Rendering{ + Generators: map[string]Generator{"mesh-network": gen}, + Settings: SettingsBy{"mesh-network": {{From: "the mesh", + Values: map[string]any{"keepalive": 25}}}}, + }) + if err != nil { + t.Fatal(err) + } + content := out[0]["content"].(string) + if !strings.Contains(content, `"keepalive": 25`) { + t.Fatalf("the setting did not reach a computed file: %s", content) + } + if !strings.Contains(content, `"peer": "workstation"`) { + t.Fatalf("the setting replaced what the generator computed: %s", content) + } +} + +func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) { + // WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning + // both is caught rather than producing a machine on two networks that each half-work. + _, err := Resolve(shelf( + Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: Offers("private-network")}, + Manifest{Module: "tailscale", Provides: Offers("private-network")}, + Manifest{Module: "backups", Requires: []string{"private-network"}}, + ), []string{"backups"}, workstation(), World{}) + + if err == nil { + t.Fatal("two answers to one requirement were taken silently") + } + for _, want := range []string{"mesh-network", "tailscale", "private-network"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no +// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file +// written into, and the runtime reloaded on it. +type reloading struct{} + +func (reloading) Resources(node string) ([]map[string]any, bool, error) { + return []map[string]any{ + {"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json", + "merge": MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`}, + {"id": "registry-trust-reload", "type": "service", "unit": "docker.service", + "state": "running", "reload-on": []string{"registry-trust"}}, + }, true, nil +} + +func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) { + // Ids are prefixed with their module on the way out. An unprefixed reload-on would name a + // resource the host never sees, and the runtime would never be reloaded for its trust. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": reloading{}}}) + if err != nil { + t.Fatal(err) + } + var file, service map[string]any + for _, r := range out { + switch r["type"] { + case "file": + file = r + case "service": + service = r + } + } + if file == nil || service == nil { + t.Fatalf("got %v", out) + } + if file["into"] != "json" { + t.Errorf("into did not reach the host: %v", file) + } + if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want { + t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"]) + } +} diff --git a/go/catalogue/config_rename_test.go b/go/catalogue/config_rename_test.go new file mode 100644 index 0000000..35ed21a --- /dev/null +++ b/go/catalogue/config_rename_test.go @@ -0,0 +1,185 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A takes-config entry may lay a value under the name the file's software reads (issue 751): the key stays a +// config key, lower-case, and the file names its own variable, such as ATLASSIAN_URL. + +// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file +// falls back on when the file lacks it. +func renaming() map[string]any { + m := inConfigForm() + m["config"].(map[string]any)["url"] = map[string]any{"type": "string", + "description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it", + "default": ""} + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", + map[string]any{"key": "url", "as": "NOTIFIER_URL"}} + return m +} + +// rulesComposed is the rules file's content as the module's defaults and these layers make it. +func rulesComposed(t *testing.T, m Manifest, layers ...Layer) string { + t.Helper() + all := layers + if d, ok := Defaults(m); ok { + all = append([]Layer{d}, layers...) + } + for _, r := range m.Resources { + if r["id"] != "rules" { + continue + } + out, err := ApplySettings(r, all) + if err != nil { + t.Fatal(err) + } + if _, left := out[TakesSettingsField]; left { + t.Fatalf("the composed file still says %s: %v", TakesSettingsField, out) + } + return out["content"].(string) + } + t.Fatal("no rules file") + return "" +} + +func TestATakenValueIsLaidUnderTheFilesOwnName(t *testing.T) { + m, err := parseRaw(t, renaming()) + if err != nil { + t.Fatalf("a renamed takes-config entry was refused: %v", err) + } + got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://hooks.example.test/n"}}) + var doc map[string]any + if err := json.Unmarshal([]byte(got), &doc); err != nil { + t.Fatal(err) + } + if doc["NOTIFIER_URL"] != "https://hooks.example.test/n" { + t.Fatalf("url was not laid as NOTIFIER_URL: %s", got) + } + if _, said := doc["url"]; said { + t.Fatalf("url was laid under the config key too: %s", got) + } +} + +func TestAnUnsetRenamedValueLeavesTheFileAsItWas(t *testing.T) { + plain := inConfigForm() + base, err := parseRaw(t, plain) + if err != nil { + t.Fatal(err) + } + m, err := parseRaw(t, renaming()) + if err != nil { + t.Fatal(err) + } + want := rulesComposed(t, base) + if got := rulesComposed(t, m); got != want { + t.Fatalf("with no layer the file is\n%s\nwanted, as without the entry,\n%s", got, want) + } + other := Layer{From: "ace", Values: map[string]any{"theme": "light"}} + if got, want := rulesComposed(t, m, other), rulesComposed(t, base, other); got != want { + t.Fatalf("with url unset the file is\n%s\nwanted\n%s", got, want) + } +} + +// A layer said before its module moved names the file's own spelling, such as nodered's timeZone on one machine. +// Until K5 moves the layer and renames the key, that spelling still reaches the file, so the move changes no +// machine's file; the config key wins where one layer says both. +func TestALayerStillSayingTheFilesNameReachesItUntilItsMove(t *testing.T) { + m, err := parseRaw(t, renaming()) + if err != nil { + t.Fatal(err) + } + for _, said := range []string{"url", "NOTIFIER_URL"} { + if stray := UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{said: "https://x.test"}}}); len(stray) > 0 { + t.Fatalf("%s, which the rules file takes, is said stray: %v", said, stray) + } + got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{said: "https://x.test"}}) + var doc map[string]any + if err := json.Unmarshal([]byte(got), &doc); err != nil { + t.Fatal(err) + } + if doc["NOTIFIER_URL"] != "https://x.test" || doc["url"] != nil { + t.Fatalf("a layer saying %s made the file %s", said, got) + } + } + both := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://key.test", "NOTIFIER_URL": "https://old.test"}}) + if !strings.Contains(both, "https://key.test") || strings.Contains(both, "old.test") { + t.Fatalf("where one layer says both, the config key did not win: %s", both) + } + later := rulesComposed(t, m, Layer{From: "mesh", Values: map[string]any{"url": "https://mesh.test"}}, + Layer{From: "ace", Values: map[string]any{"NOTIFIER_URL": "https://ace.test"}}) + if !strings.Contains(later, "https://ace.test") { + t.Fatalf("a later layer's old spelling did not beat an earlier layer's key: %s", later) + } + // Who may set it is the same under either spelling: the rules file is trusted, so both are the terminal's. + terminal := TerminalKeys(m) + for _, k := range []string{"url", "NOTIFIER_URL"} { + if !listHolds(terminal, k) { + t.Fatalf("%s is not the terminal's in a trusted file: %v", k, terminal) + } + } +} + +func listHolds(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) { + m, err := parseRaw(t, renaming()) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) { + t.Fatalf("written back as %s", raw) + } + if _, err := ParseManifest(raw); err != nil { + t.Fatalf("read again: %v", err) + } +} + +func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) { + entry := func(m map[string]any, e any) map[string]any { + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e} + return m + } + refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`) + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`) + refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key") + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name") + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads") + refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice") + refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice") + refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value") + + kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"}) + resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"} + refused(t, kept, `"NOTIFIER_URL"`, "protected") + + // Laid only when set, so the code's own fallback holds: never required. + required := renaming() + declaration(required, "url")["required"] = true + delete(declaration(required, "url"), "default") + refused(t, required, `"url"`, "only when it is set", "never required") +} + +func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) { + m := map[string]any{ + "module": "notifier", "version": "1", + "resources": []any{ + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}", + "takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}}, + }, + } + refused(t, m, "notifier's rules", "config form") +} diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go new file mode 100644 index 0000000..fd7f55e --- /dev/null +++ b/go/catalogue/config_test.go @@ -0,0 +1,484 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" +) + +// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660): +// one registration refusal per rule of design 57's K1, the old names read as aliases, and a module rewritten +// from settings to config composing byte-identically. + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +// declaration is one value of inConfigForm's config, to change in place. +func declaration(m map[string]any, key string) map[string]any { + return m["config"].(map[string]any)[key].(map[string]any) +} + +func resourceOf(m map[string]any, id string) map[string]any { + for _, r := range m["resources"].([]any) { + if r.(map[string]any)["id"] == id { + return r.(map[string]any) + } + } + return nil +} + +func parseRaw(t *testing.T, m map[string]any) (Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return ParseManifest(raw) +} + +// refused is that the manifest is refused with every one of these words. +func refused(t *testing.T, m map[string]any, words ...string) { + t.Helper() + _, err := parseRaw(t, m) + if err == nil { + t.Fatalf("registered; wanted a refusal saying %q", words) + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Fatalf("refused without %q:\n%v", w, err) + } + } +} + +func TestAModuleInTheConfigFormRegisters(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + if !m.DeclaresConfig() { + t.Fatal("a module declaring config is not read as in the config form") + } + d := m.Settings["font-size"] + if d.Type != "number" || d.Description == "" || d.Minimum == nil || *d.Minimum != 6 || d.Default != float64(10) { + t.Fatalf("font-size read as %+v", d) + } + if s := m.Settings["sender"]; !s.Required || s.Default != nil { + t.Fatalf("sender read as %+v", s) + } +} + +// Rule 1: a value without type or description. +func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "type") + refused(t, m, `notifier's config value "width"`, "no type") + m = inConfigForm() + declaration(m, "width")["type"] = "colour" + refused(t, m, `"width"`, `type "colour"`) + m = inConfigForm() + delete(declaration(m, "width"), "description") + refused(t, m, `"width"`, "no description") +} + +// Rule 2: a value with neither a default nor required. +func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "default") + refused(t, m, `"width"`, "neither a default nor required") + m = inConfigForm() + declaration(m, "sender")["default"] = "noreply@example.org" + refused(t, m, `"sender"`, "both a default and required") +} + +// Rule 3: a default failing its validation. +func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) { + for _, c := range []struct { + key string + value any + said string + }{ + {"font-size", 4, "below its minimum"}, + {"font-size", 41, "above its maximum"}, + {"font-size", "10", "not a number"}, + {"position", "bottom", "not one of"}, + {"theme", "Dark", "does not match"}, + } { + m := inConfigForm() + declaration(m, c.key)["default"] = c.value + refused(t, m, `"`+c.key+`"`, c.said) + } +} + +// Rule 4: an object or a list not trusted. +func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) { + for _, kind := range []string{"object", "list"} { + m := inConfigForm() + def := any(map[string]any{"a": 1}) + if kind == "list" { + def = []any{"a"} + } + m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind, + "default": def, "runtime": true} + refused(t, m, `"rules"`, "is "+kind+" and not trusted") + m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted %s is refused: %v", kind, err) + } + } +} + +// withPredefined is the list of predefined keys the mesh published, for the length of a test. +func withPredefined(t *testing.T, keys PredefinedList) { + t.Helper() + was := ReplacePublished(keys) + t.Cleanup(func() { ReplacePublished(was) }) +} + +// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is +// refused saying so. +func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { + withPredefined(t, nil) + m := inConfigForm() + declaration(m, "font-size")["predefined"] = "font-size" + refused(t, m, `"font-size"`, "the list of predefined keys", "is not present") + + withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}}) + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a value falling back to a listed key of its type is refused: %v", err) + } + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "font-scale" + refused(t, m, `"font-size"`, `"font-scale"`, "not on the list") + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "accent" + refused(t, m, `"font-size"`, `"accent"`, "is a string") +} + +// Rule 6: an untrusted value falling back to a trusted predefined key. +func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) { + withPredefined(t, nil) + m := inConfigForm() + declaration(m, "theme")["predefined"] = "theme" + refused(t, m, `"theme"`, "is not present") + + withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}}) + refused(t, m, `"theme"`, "trusted", "is not") + declaration(m, "theme")["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err) + } +} + +// Rule 7: a value neither placed in a template nor read at runtime. +func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "history"), "runtime") + refused(t, m, `"history"`, "${config:history}", `"runtime": true`) + // Taken by a mergeable file is placed. + m = inConfigForm() + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } +} + +// Rule 8: a placeholder naming an undeclared value. +func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" + + "from = ${config:sender}\ncolour = ${config:colour}\n" + refused(t, m, "${config:colour}", "declares no such value") +} + +// Rule 9: a reload-on or restart-on naming a file the module does not declare. +func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) { + m := inConfigForm() + resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"} + refused(t, m, "reload-on", `"dunstrc"`, "declares no such") + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rule"} + refused(t, m, "restart-on", `"rule"`) + // Another module's file, and one the mesh makes for the module, are not the module's to declare. + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"} + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("refused: %v", err) + } +} + +// Rule 10: a moved module's mergeable file not naming its keys under takes-config. +func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) { + m := inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + // position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks. + declaration(m, "position")["runtime"] = true + declaration(m, "theme")["runtime"] = true + refused(t, m, "rules", "names no takes-config") +} + +// Rule 11: a mesh word, puid or pgid in takes-config. +func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) { + for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word} + refused(t, m, `"`+word+`"`, "takes-config") + } +} + +// A module uses one form or the other, never both. +func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n" + refused(t, m, "mixes", "${setting:", "config") + + m = inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"} + refused(t, m, "mixes", "takes-settings") + + m = inConfigForm() + m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}} + refused(t, m, "mixes", "settings") +} + +// The settings form keeps its own declaration; the config form's has no kind or why. +func TestEachFormKeepsItsOwnDeclaration(t *testing.T) { + m := inConfigForm() + declaration(m, "width")["why"] = "fits a title" + refused(t, m, `"width"`, "why") + + err := parsed(t, Manifest{Module: "notifier", + Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250), + Why: "fits a title", Type: "number"}}, + Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}}) + if err == nil || !strings.Contains(err.Error(), "under config") { + t.Fatalf("a typed value under settings: %v", err) + } +} + +// What the mesh holds is what was written: a module in the config form is written back in it, and read again +// the same. +func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + for _, said := range []string{`"config":`, `${config:font-size}`, `"takes-config":`, `"required":true`} { + if !strings.Contains(string(raw), said) { + t.Fatalf("written back without %s: %s", said, raw) + } + } + for _, unsaid := range []string{`"settings":`, `${setting:`, `"takes-settings"`, `"kind"`, `"why"`, `"default":null`} { + if strings.Contains(string(raw), unsaid) { + t.Fatalf("written back with %s: %s", unsaid, raw) + } + } + again, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + twice, _ := json.Marshal(again) + if !bytes.Equal(raw, twice) { + t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw) + } +} + +// A module in the settings form is written back byte for byte as before. +func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) { + d := SettingDeclaration{Kind: KindPreference, Default: float64(10), Why: "readable"} + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"kind":"preference","default":10,"why":"readable"}` { + t.Fatalf("written as %s", raw) + } +} + +// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to +// config. +const settingsDunst = `{"module": "dunst", "version": "1", + "settings": { + "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, + "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +const configDunst = `{"module": "dunst", "version": "1", + "config": { + "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, + "maximum": 40, "default": 10}, + "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, + "default": 250} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes +// to the same bytes, with no layer, with the node's, and with the mesh's under the node's. +func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { + composed := func(manifest string, layers []Layer) []byte { + t.Helper() + m, err := ParseManifest([]byte(manifest)) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + with := anchorRendering(false) + if layers != nil { + with.Settings["dunst"] = layers + } + c, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + if why, left := c.LeftOut["dunst"]; left { + t.Fatalf("dunst was left out: %s", why) + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, layers := range [][]Layer{ + nil, + {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, + {{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, + } { + was, is := composed(settingsDunst, layers), composed(configDunst, layers) + if !bytes.Equal(was, is) { + t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) + } + if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { + t.Fatalf("not filled: %s", is) + } + } +} + +// A key a mergeable file takes is a config value like every other: one not declared under config is refused by +// name (ADR 0348 §4, review of mesh-controller#320). +func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"} + refused(t, m, `"urgency"`, "takes-config", "declares no such value under config") +} + +// A required value nothing sets is refused at composition in the config form's words: ${config:} and the +// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. +func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + composed, err := r.Compose(anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["notifier"] + if !left { + t.Fatal("a module whose required value nothing sets was not left out") + } + for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { + if !strings.Contains(why, said) { + t.Fatalf("left out without %q: %s", said, why) + } + } + for _, unsaid := range []string{"${setting:", "settings set"} { + if strings.Contains(why, unsaid) { + t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) + } + } + var unset *UnsetSettingError + if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, + "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { + t.Fatalf("not typed as a value nobody gave: %v", err) + } + + settingsForm := notifier() + settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" + err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) + if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { + t.Fatalf("the settings form's refusal changed: %v", err) + } +} + +// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when +// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the +// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the +// operator's decision on issue 664's second question (issue 735). +func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) { + m := inConfigForm() + m["config"].(map[string]any)["url"] = map[string]any{"type": "string", + "description": "the address the notifier posts to, a URL; empty for the one the file's software picks", + "default": ""} + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"} + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err) + } + + // A placeholder asking for it has no fallback but the default, which would put a value in the definition + // where the operator's belongs (ADR 0112). + asked := inConfigForm() + asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""} + resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"} + resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" + + "from = ${config:sender}\npost = ${config:url}\n" + refused(t, asked, `"url"`, `a key naming "url" is the operator's value`) + + // Read by the module's code alone, nothing in the definition says it falls back. + runtime := inConfigForm() + runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", + "default": "", "runtime": true} + refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`) +} diff --git a/go/catalogue/consumes_start_test.go b/go/catalogue/consumes_start_test.go new file mode 100644 index 0000000..3374792 --- /dev/null +++ b/go/catalogue/consumes_start_test.go @@ -0,0 +1,24 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// 1279/AC4 (ADR 0443 §4): no module is given a way to ask where its consumer starts. A consumes entry is an event's +// name; one that carries a key asking for the stream's history is refused by name, so the safe start is the only one. +func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4 + for _, key := range []string{"from", "start", "deliver"} { + raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}` + _, err := ParseManifest([]byte(raw)) + if err == nil { + t.Fatalf("a consumes entry with %q was accepted", key) + } + if !strings.Contains(err.Error(), `"`+key+`"`) || !strings.Contains(err.Error(), "consumes") { + t.Errorf("the refusal for %q does not name the key and the field: %v", key, err) + } + } + if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil { + t.Errorf("a plain consumes entry was refused: %v", err) + } +} diff --git a/go/catalogue/contributes_test.go b/go/catalogue/contributes_test.go new file mode 100644 index 0000000..beb6ab6 --- /dev/null +++ b/go/catalogue/contributes_test.go @@ -0,0 +1,412 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// The other half of an edge. +// +// `requires` says a thing must be there. It never said what to do with it — a web application +// requiring a reverse proxy has to say *which name, which port*, and there was nowhere to put +// that. The two modules that needed it most, the proxy and the VPN, went round the outside and +// opened a connection to the control plane's database, which is why every node held a credential +// to it permanently. + +func published(module, host string, port int) Manifest { + return Manifest{Module: module, Version: "1", + Contributes: map[string]map[string]any{ + "reverse-proxy": {"host": host, "port": port}, + }} +} + +func proxy() Manifest { + return Manifest{Module: "traefik", Version: "1", + Provides: Offers("reverse-proxy"), + Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"}, + Resources: []map[string]any{ + {"id": "up", "type": "service", "unit": "traefik", "state": "running", + "restart-on": []any{ReceivedID("reverse-proxy")}}, + }} +} + +// received digs the delivered contributions back out of a declaration. +func received(t *testing.T, out []map[string]any) []Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + body := r["content"].(string) + var parsed struct { + Requirement string `json:"requirement"` + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body) + } + if parsed.Requirement != "reverse-proxy" { + t.Fatalf("the file does not say what it is about: %q", parsed.Requirement) + } + return parsed.Given + } + t.Fatalf("the provider was given no file at all: %v", out) + return nil +} + +func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) { + // It is written for a program, and the first version put a `//` header above the JSON — a + // file that says "do not edit" to a person and fails to parse for the thing meant to read it. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + for _, r := range mustDeclare(t, got) { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + var any map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil { + t.Fatalf("the file is not parseable: %v\n%s", err, r["content"]) + } + if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") { + // Inside the document rather than above it, so it reaches a person without + // breaking the parse. + t.Fatalf("the file does not say it is generated: %v", any["generated"]) + } + return + } + t.Fatal("no received file") +} + +func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) { + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if len(given) != 1 || given[0].From != "board" { + t.Fatalf("the proxy was not told about board: %v", given) + } + if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) { + t.Fatalf("the contribution did not survive: %v", given[0].Values) + } +} + +func TestContributingToSomethingIsRequiringIt(t *testing.T) { + // Asking to be published means a publisher must exist. A module that had to say both would + // eventually say one, and the failure would be a machine where nothing serves the route. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(names(got), " "), "traefik") { + t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got)) + } +} + +func TestNothingToContributeToIsRefused(t *testing.T) { + _, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + if err == nil { + t.Fatal("a module was published through a proxy that does not exist") + } + if !strings.Contains(err.Error(), "reverse-proxy") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) { + got, err := Resolve(shelf(proxy(), + published("board", "board", 8080), + published("archive", "archive", 9000), + ), []string{"board", "archive"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 2 { + t.Fatalf("the proxy was told about %d of 2: %v", len(given), given) + } + // Ordered by module, because this becomes a file and a file whose lines move about looks + // changed when nothing changed — which would restart the proxy for ever. + if given[0].From != "archive" || given[1].From != "board" { + t.Fatalf("the order is not stable: %v", given) + } +} + +func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) { + // Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me" + // from "the mesh never wrote it", and those want completely different responses — the same + // rule the host follows about a service that does not exist. + got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if given := received(t, mustDeclare(t, got)); len(given) != 0 { + t.Fatalf("got %v", given) + } +} + +func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) { + // A proxy that got a new route and did not reload is a route that silently does not work. + // The same fault the private network had when a peer list changed under a running interface, + // which is why the received file has a name a module can point at. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + out := mustDeclare(t, got) + for _, r := range out { + if r["type"] != "service" { + continue + } + reflects, ok := r["restart-on"].([]any) + if !ok || len(reflects) != 1 { + t.Fatalf("the proxy does not reflect anything: %v", r) + } + if reflects[0] != "traefik."+ReceivedID("reverse-proxy") { + t.Fatalf("it reflects %v, which is not the file it was given", reflects[0]) + } + return + } + t.Fatal("no service in the declaration") +} + +func TestARouteCanBeSetPerMesh(t *testing.T) { + // The hostname is exactly the kind of thing that differs between one mesh and the next. A + // module whose route could not be set would have to be edited to be reused anywhere. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + out, err := got.Declaration(Rendering{Settings: SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not reach the route: %v", given[0].Values) + } + if given[0].Values["port"] != float64(8080) { + t.Fatalf("setting the host dropped the port: %v", given[0].Values) + } +} + +func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { + // novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read + // it, arrived in every route it contributed. A setting overrides a key the contribution + // declares and adds none — the provider reads the contribution as a contract. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{}) + + out, err := got.Declaration(Rendering{Settings: SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not override the route's host: %v", given[0].Values) + } + if _, leaked := given[0].Values["sitename"]; leaked { + t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values) + } +} + +func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { + // It would create a file nobody ever writes to, on a machine where nothing asked for it. + _, err := ParseManifest([]byte(`{"module":"traefik","version":"1", + "receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`)) + if err == nil { + t.Fatal("a module received contributions to something it does not provide") + } + if !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestAnEmptyContributionIsRefused(t *testing.T) { + // Either a mistake or a requirement written the long way round, and both are better said. + _, err := ParseManifest([]byte(`{"module":"board","version":"1", + "contributes":{"reverse-proxy":{}}}`)) + if err == nil { + t.Fatal("a module contributed nothing and was accepted") + } + if !strings.Contains(err.Error(), "require it") { + t.Fatalf("the refusal does not say what to do instead: %v", err) + } +} + +// A provision answered from anywhere in the mesh has consumers on other machines, and the +// provider has to know who they are. Contributions were node-local until this, which meant the +// one case that most needed them was the one they did not reach. + +func provider() Manifest { + return Manifest{Module: "postgres", Version: "1", + Provides: FromAnywhere("postgres-database"), + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + } +} + +// oneGrant is a declaration with a single consumer on another machine. +func oneGrant(t *testing.T) []map[string]any { + t.Helper() + got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Grants: []Grant{{ + Provision: "postgres-database", Consumer: "workstation", From: "meshboard", + Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk", + }}}) + if err != nil { + t.Fatal(err) + } + return out +} + +func grantedTo(t *testing.T, out []map[string]any) []Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/var/lib/postgres/grants/mesh.json" { + continue + } + var parsed struct { + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("the provider was given no manifest: %v", out) + return nil +} + +func TestAProviderIsToldAboutConsumersOnOtherMachines(t *testing.T) { + // A database told to create a password and not who for can do nothing with it. + given := grantedTo(t, oneGrant(t)) + if len(given) != 1 { + t.Fatalf("got %v", given) + } + if given[0].Node != "workstation" || given[0].From != "meshboard" { + t.Fatalf("it does not say who asked: %v", given[0]) + } + if given[0].Values["name"] != "meshboard" { + t.Fatalf("it does not say what was asked for: %v", given[0].Values) + } +} + +func TestTheManifestNamesTheFileRatherThanCarryingTheCredential(t *testing.T) { + // The mesh discarded the value and could not put it here if it wanted to. What is here is + // where to find it — and the readable half therefore stays readable. + out := oneGrant(t) + given := grantedTo(t, out) + // Named after the machine and the module, because a consumer is both (novox/hq + // 04-ISSUES/022). The machine alone, two modules on one node wrote to one path. + if given[0].Secret != "/var/lib/postgres/grants/workstation.meshboard.secret" { + t.Fatalf("the manifest does not name the credential's file: %q", given[0].Secret) + } + for _, r := range out { + if r["path"] != "/var/lib/postgres/grants/mesh.json" { + continue + } + if strings.Contains(r["content"].(string), "c2VhbGVk") { + t.Fatal("the readable manifest carries the sealed credential") + } + } +} + +func TestTheCredentialItselfLandsSealedBesideIt(t *testing.T) { + for _, r := range oneGrant(t) { + if r["path"] != "/var/lib/postgres/grants/workstation.meshboard.secret" { + continue + } + if r["sealed"] != "c2VhbGVk" { + t.Fatalf("got %v", r) + } + if r["content"] != nil { + t.Fatal("a credential was written in the clear") + } + return + } + t.Fatal("no credential file") +} + +func TestAConsumersOwnContributionsAreStillThere(t *testing.T) { + // Cross-node grants are merged in with this machine's own, because from the provider's side + // they are the same thing — somebody wanting something — and a provider that had to read two + // lists would read one of them. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 1 || given[0].Node != "" { + t.Fatalf("a local contribution grew a node: %v", given) + } +} + +func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) { + // A consumer that requires a parameterless provision (one whose serves names no consumer key — + // redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be + // granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as + // withdrawn — so the provider never created the account and the consumer authenticated nowhere. + r := Resolution{ + Node: "laptop", + Modules: []Manifest{{ + Module: "amqp-ping", + Requires: []string{"amqp"}, + }}, + } + values, asks, err := r.ContributionsFrom("amqp", "amqp-ping", SettingsBy{}) + if err != nil { + t.Fatal(err) + } + if !asks { + t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned") + } + if values == nil { + t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values) + } +} + +func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) { + // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for, + // and it can only do that if the mesh stops asking — a consumer that was unassigned would + // otherwise keep a working login for ever, and nothing would say so. + // + // A grant with no asking module is exactly that state: the mesh still holds the secret, + // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it. + got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Grants: []Grant{ + {Provision: "postgres-database", Consumer: "still-here", From: "meshboard", + Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"}, + {Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="}, + }}) + if err != nil { + t.Fatal(err) + } + + given := grantedTo(t, out) + if len(given) != 1 || given[0].Node != "still-here" { + t.Fatalf("the provider is still told about a machine that stopped asking: %v", given) + } + // And no credential is written for it either, or the provisioner would find a file for a + // consumer its manifest does not mention and have to guess what that means. + for _, r := range out { + if path, _ := r["path"].(string); strings.Contains(path, "gone-away") { + t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r) + } + } +} diff --git a/go/catalogue/contribution_dependencies_test.go b/go/catalogue/contribution_dependencies_test.go new file mode 100644 index 0000000..91e9bc0 --- /dev/null +++ b/go/catalogue/contribution_dependencies_test.go @@ -0,0 +1,136 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0210 §3: a contribution is a dependency on the seat that receives it. + +func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) { + env := mod("theme", nil, nil, nil) + env.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}} + path := mod("toolchain", nil, nil, nil) + path.Environment = &Environment{Path: []PathEntry{{Entry: "/opt/x/bin"}}} + shell := mod("prompt", nil, nil, nil) + shell.Shell = []ShellCode{{For: "zsh", Slot: "first", Code: "true"}} + session := mod("wallpaper", nil, nil, nil) + session.Shell = []ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}} + resources := mod("bar", nil, nil, nil) + resources.Shell = []ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}} + empty := mod("nothing", nil, nil, nil) + empty.Environment = &Environment{} + + cases := map[string]struct { + m Manifest + want []string + }{ + "a variable": {env, []string{EnvironmentSeat}}, + "a path entry": {path, []string{EnvironmentSeat}}, + "shell code": {shell, []string{LoginShellSeat}}, + "the session's start": {session, []string{DisplayServerSeat}}, + "the session's X resources": {resources, []string{DisplayServerSeat}}, + "an empty environment": {empty, nil}, + } + for name, c := range cases { + got := DependsOn(nil, c.m) + if len(got) == 0 { + got = nil + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s depends on %v, want %v", name, got, c.want) + } + } +} + +func TestAContributionIsMetByAHolderOnTheNodeAndRefusedWithout(t *testing.T) { + holder := mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat}) + contributor := mod("theme", nil, nil, nil) + contributor.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}} + catalogue := map[string]Manifest{"node-env": holder, "theme": contributor} + + if _, err := AssignRefusal(catalogue, "laptop", []string{"node-env"}, []string{"theme"}); err != nil { + t.Fatalf("a contributor beside the holder is refused: %v", err) + } + _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme"}) + if err == nil { + t.Fatal("a contributor on a node without the holder was accepted, and its contribution would be written nowhere") + } + if !strings.Contains(err.Error(), EnvironmentSeat) || !strings.Contains(err.Error(), "node-env") { + t.Errorf("the refusal names neither the seat nor its holder: %v", err) + } + if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme", "node-env"}); err != nil { + t.Errorf("the contributor and the holder assigned together are refused: %v", err) + } +} + +func TestAHolderMeetsItsOwnContribution(t *testing.T) { + // The display server's module contributes nothing to its own seat today, but the zsh module's + // environment contributions do go to another seat: a claim meets only the seat it names. + zsh := mod("zsh", nil, nil, nil, Claim{Name: LoginShellSeat}) + zsh.Shell = []ShellCode{{For: "zsh", Slot: "normal", Code: "true"}} + zsh.Environment = &Environment{Variables: map[string]string{"EDITOR": "vim"}} + catalogue := map[string]Manifest{"zsh": zsh, + "node-env": mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})} + unheld := UnheldDependencies(catalogue, "laptop", []Manifest{zsh}, nil) + if len(unheld) != 1 || unheld[0].Seat != EnvironmentSeat { + t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, EnvironmentSeat) + } +} + +func TestTwoModulesMayDeclareOnePackage(t *testing.T) { + pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), + res("package", "pacman-contrib")) + bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib")) + catalogue := map[string]Manifest{"pacman": pacman, "bar": bar} + if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"}); err != nil { + t.Fatalf("a second declaration of a package was refused (novox/hq ADR 0303): %v", err) + } +} + +func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) { + gone := res("package", "ufw") + gone["absent"] = true + filter := withResources(mod("nftables", nil, nil, nil), gone) + wants := withResources(mod("legacy", nil, nil, nil), res("package", "ufw")) + other := withResources(mod("other", nil, nil, nil), res("package", "other")) + catalogue := map[string]Manifest{"nftables": filter, "legacy": wants, "other": other} + + err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"legacy"}) + if err == nil || !strings.Contains(err.Error(), "ufw") || !strings.Contains(err.Error(), "nftables") || + !strings.Contains(err.Error(), "legacy") || !strings.Contains(err.Error(), "ADR 0303") { + t.Fatalf("a package declared present and absent was not refused naming both: %v", err) + } + if strings.Contains(err.Error(), "one owner per node") { + t.Errorf("the refusal gives the one-owner rule, which no longer holds for packages: %v", err) + } + if err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"other"}); err != nil { + t.Errorf("a module declaring nothing contradictory is refused: %v", err) + } + // A contradiction already on the node is status's, not a reason to refuse an unrelated assignment. + if err := CollisionRefusal(catalogue, "laptop", []string{"nftables", "legacy"}, []string{"other"}); err != nil { + t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err) + } +} + +func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { + for _, moment := range powerMoments { + m := mod("laptop", nil, nil, nil) + m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}} + if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) { + t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat) + } + if p := m.shellProblems(); len(p) != 0 { + t.Errorf("code for %s is refused: %v", moment, p) + } + } + m := mod("laptop", nil, nil, nil) + m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}} + if p := m.shellProblems(); len(p) == 0 { + t.Error("code for a moment that does not exist was accepted") + } + if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode { + t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok) + } +} diff --git a/go/catalogue/core_credentials_test.go b/go/catalogue/core_credentials_test.go new file mode 100644 index 0000000..1c123f3 --- /dev/null +++ b/go/catalogue/core_credentials_test.go @@ -0,0 +1,107 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// The core's own credentials travel sealed by the vault to their machine (novox/hq ADR 0349 §16, issue 755): the +// controller carries the vault's box and cannot open it. The list is fixed here, and the composition carries the box +// in place of the controller's own sealed value only where the rendering was given one. + +func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) { + if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(CoreCredentialModules, want) { + t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", CoreCredentialModules, want) + } + for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} { + if !CarriesVaultSealed(m) { + t.Errorf("%s is not on the list", m) + } + } + for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} { + if CarriesVaultSealed(m) { + t.Errorf("%s is on the list", m) + } + } +} + +const provisionerClient = `{"module":"mesh-provisioner-client","version":"1", + "own-secrets":{"provisioner-broker":"${dir:state}/broker"}, + "resources":[{"id":"state","type":"directory","mode":"0700","place":"."}]}` + +func clientOn(t *testing.T) Resolution { + t.Helper() + m, err := ParseManifest([]byte(provisionerClient)) + if err != nil { + t.Fatal(err) + } + return notifierOn(t, m) +} + +func brokerFile(t *testing.T, c Composed) map[string]any { + t.Helper() + for _, r := range c.Resources { + if r["id"] == "mesh-provisioner-client.needs-provisioner-broker" { + return r + } + } + return nil +} + +var aVaultBox = json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Yw=="}`) + +func TestACoreCredentialCarriesTheVaultsBoxAndNoSealOfTheControllers(t *testing.T) { + r := clientOn(t) + with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}, + VaultSealed: map[string]map[string]json.RawMessage{"mesh-provisioner-client": {"provisioner-broker": aVaultBox}}} + c, got := composeWith(t, r, with) + f := brokerFile(t, c) + if f == nil { + t.Fatalf("the credential's file is not declared: %s", got) + } + if f["sealed"] != nil || !reflect.DeepEqual(f["vault-sealed"], aVaultBox) || strings.Contains(got, `"nacl"`) { + t.Fatalf("the file is %v: the vault's box alone, never the controller's seal", f) + } + // Without the controller's own value at all: the vault's box is enough. + with.Needed = nil + if c, _ := composeWith(t, r, with); brokerFile(t, c) == nil { + t.Fatal("a core credential with the vault's box and no seal of the controller's was refused or dropped") + } +} + +func TestACoreModuleTheVaultGaveNothingForIsLeftOutWithWhy(t *testing.T) { + r := clientOn(t) + c, got := composeWith(t, r, Rendering{NotPlaced: map[string]string{ + "mesh-provisioner-client": "the vault did not answer for its credential"}}) + if brokerFile(t, c) != nil || strings.Contains(got, "mesh-provisioner-client.") { + t.Fatalf("a core module the vault gave nothing for was placed: %s", got) + } + if !strings.Contains(c.LeftOut["mesh-provisioner-client"], "the vault did not answer") { + t.Fatalf("left out %v", c.LeftOut) + } +} + +// An engine below VaultBoxContract is rendered with neither field: its composition is the controller's sealed value, +// byte for byte what it was. +func TestWithoutTheVaultsBoxTheCompositionIsAsBefore(t *testing.T) { + r := clientOn(t) + with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}} + before, _ := composeWith(t, r, with) + with.VaultSealed, with.NotPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} + after, _ := composeWith(t, r, with) + if !reflect.DeepEqual(before, after) || brokerFile(t, before)["sealed"] != "nacl" { + t.Fatalf("the composition changed: %v", brokerFile(t, after)) + } +} + +// Asked ahead of a send that has no box kept yet: a stand-in, named as foreseen, never sent. +func TestAForeseenVaultBoxIsNamed(t *testing.T) { + r := clientOn(t) + c, _ := composeWith(t, r, Rendering{VaultSealed: map[string]map[string]json.RawMessage{ + "mesh-provisioner-client": {"provisioner-broker": ForeseenVaultBox}}}) + if !reflect.DeepEqual(c.Foreseen, []string{"mesh-provisioner-client/provisioner-broker"}) { + t.Fatalf("foreseen %v", c.Foreseen) + } +} diff --git a/go/catalogue/data_test.go b/go/catalogue/data_test.go new file mode 100644 index 0000000..9511cda --- /dev/null +++ b/go/catalogue/data_test.go @@ -0,0 +1,227 @@ +package catalogue + +import ( + "slices" + "strings" + "testing" +) + +// A store as a module now declares it (novox/hq ADR 0233): its own data by directory with a class and a +// dump, and what it keeps for its consumers. +const declaredStore = `{"module":"pg","version":"1", + "provides":[{"name":"postgres-database","scope":"mesh"}], + "grants":{"postgres-database":"${dir:grants}"}, + "data":{ + "own":[ + {"id":"store","path":"${dir:store}","class":"irreplaceable","backup":{"dump":"dump-all > ${dir:dumps}/all","into":"dumps"},"active":"1d"}, + {"id":"dumps","path":"${dir:dumps}","class":"rebuildable","backup":"none"}], + "consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}}, + "resources":[ + {"id":"grants","type":"directory","mode":"0700"}, + {"id":"store","type":"directory","path":"/srv/store","mode":"0700"}, + {"id":"dumps","type":"directory","path":"${dir:store}/dumps","mode":"0700"}, + {"id":"server","type":"container","image":"x@sha256:aa","volumes":["${dir:store}:/var/lib/postgresql/data"]}]}` + +func mustParse(t *testing.T, raw string) Manifest { + t.Helper() + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatalf("refused: %v", err) + } + return m +} + +func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) { + m := mustParse(t, declaredStore) + if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 { + t.Fatalf("a store declaring its data was refused: %v", problems) + } + if !m.KeepsConsumerData("postgres-database") { + t.Fatal("an irreplaceable consumer class does not keep the consumer's data") + } + if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 { + t.Fatalf("the store item reads %+v", it) + } + if it, _ := m.DataItem("dumps"); it.BackedUp() { + t.Fatal("a rebuildable item that says none is backed up") + } +} + +// Every rule of the section itself, refused at parse in the words of the module. +func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) { + for _, c := range []struct{ name, data, want string }{ + {"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"}, + {"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"}, + {"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"}, + {"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"}, + {"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"}, + {"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"}, + {"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"}, + {"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"}, + {"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"}, + {"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"}, + {"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"}, + {"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"}, + {"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"}, + {"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"}, + {"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"}, + {"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"}, + {"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"}, + {"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"}, + {"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"}, + } { + raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}` + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want %q", c.name, err, c.want) + } + } +} + +// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup +// line by hand, and every directory a container writes is declared (novox/hq ADR 0233). +func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) { + unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}], + "grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`) + onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`) + byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`) + writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}, + {"id":"c","type":"directory","mode":"0700"}, + {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`) + problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n") + for _, want := range []string{ + "q grants queue and does not say what it keeps", + "r says keeps-consumer-data on its offer", + "h's contribution 1 is a backup line written by hand", + `w mounts its directory "d" into a container to be written`, + } { + if !strings.Contains(problems, want) { + t.Errorf("the check does not say %q:\n%s", want, problems) + } + } + if strings.Contains(problems, `"c"`) { + t.Errorf("a read-only mount was taken for written data:\n%s", problems) + } + // The same module declaring the directory, as a cache, passes. + declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]}, + "resources":[{"id":"d","type":"directory","mode":"0700"}, + {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`) + if p := DataProblems(Shelf{"w": declared}); len(p) > 0 { + t.Fatalf("a declared directory is still refused: %v", p) + } +} + +// Consumer data said to live in a provision the module requires is protected only as well as that +// provision's provider protects its consumers' data. +func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { + idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"], + "provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"}, + "resources":[{"id":"g","type":"directory","mode":"0700"}], + "data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`) + cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`, + `"consumers":{"postgres-database":{"class":"cache"}}`, 1)) + if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") { + t.Fatalf("irreplaceable data kept in a cache passed: %s", p) + } + if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 { + t.Fatalf("refused against a provider that keeps its consumers' data: %v", p) + } +} + +// What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers +// move freely, a store's do not; an older definition saying nothing still follows its grant. +func TestKeepingConsumerDataFollowsTheClass(t *testing.T) { + for class, keeps := range map[string]bool{"irreplaceable": true, "valuable": true, "rebuildable": true, "cache": false, "none": false} { + in := `,"in":"d"` + own := `"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable"}],` + if !keeps { + in, own = "", "" + } + m := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"}, + "resources":[{"id":"d","type":"directory","mode":"0700"}], + "data":{`+own+`"consumers":{"q":{"class":"`+class+`"`+in+`}}}}`) + if m.KeepsConsumerData("q") != keeps { + t.Errorf("class %s keeps consumer data: %v, want %v", class, !keeps, keeps) + } + shelf := map[string]Manifest{"p": m} + if KeepsConsumerData(shelf, "q") != keeps { + t.Errorf("class %s: the provision by name keeps: %v", class, !keeps) + } + } + older := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"}, + "resources":[{"id":"d","type":"directory","mode":"0700"}]}`) + if !older.KeepsConsumerData("q") { + t.Fatal("an older definition that grants no longer keeps its consumers' data") + } +} + +// The backup holder's lines are derived: the dump runs and the directory it writes into is kept, +// anything but a cache is copied by default — the standard nightly plan — an item on redundancy is not +// copied, and every item is listed with its class and protection for the holder to measure and watch: +// directories filled, a home directory filled from the machine, an operator's path from where the +// assignment placed it. A backup line still written by hand beside a data section is not placed. +func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { + pg := mustParse(t, declaredStore) + pg.Contributions = append(pg.Contributions, SeatContribution{Seat: BackupSeat, Kind: "backup", Content: "path /stale"}) + agent := mustParse(t, `{"module":"agent","version":"1", + "data":{"own":[{"id":"home","path":"${dir:home}","class":"valuable"},{"id":"tmp","path":"${dir:home}/tmp","class":"cache"}]}, + "resources":[{"id":"home","type":"directory","path":"${machine:account-home}/.agent","mode":"0700"}]}`) + media := mustParse(t, `{"module":"media","version":"1", + "accesses":[{"id":"films","mode":"read"}], + "data":{"own":[{"id":"films","path":"${access:films}","class":"irreplaceable","redundancy":"on a redundant array; no room for a copy","measure":"dataset"}, + {"id":"meta","path":"${dir:meta}","class":"rebuildable"}]}, + "resources":[{"id":"meta","type":"directory","mode":"0700"}]}`) + facts := map[string]string{"account-home": "/home/op"} + with := Rendering{Settings: SettingsBy{"media": {LayerOf("node", map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}})}}} + backup, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil) + if err != nil { + t.Fatal(err) + } + want := "# agent\npath /home/op/.agent\n# media\npath /var/lib/media/meta\n# pg\nrun dump-all > /srv/store/dumps/all\npath /srv/store/dumps\n" + if backup != want { + t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want) + } + data, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil) + if err != nil { + t.Fatal(err) + } + want = "# agent\nitem home valuable /home/op/.agent /home/op/.agent backup walk\nitem tmp cache /home/op/.agent/tmp - none walk\n" + + "# media\nitem films irreplaceable /tank/films - redundancy dataset\nitem meta rebuildable /var/lib/media/meta /var/lib/media/meta backup walk\n" + + "# pg\nitem store irreplaceable /srv/store /srv/store/dumps backup walk\nitem dumps rebuildable /srv/store/dumps - none walk\n" + if data != want { + t.Fatalf("data lines:\n%s\nwant:\n%s", data, want) + } + if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil { + t.Fatal("a home directory with no account on the machine reached the holder as a placeholder") + } + // What keeps something irreplaceable depends on the machine's backup holder — it backs it up or + // watches its array; valuable data alone is backed up where a holder is, and refused nowhere. + if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) || + slices.Contains(DependsOn(nil, agent), BackupSeat) { + t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent)) + } + if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" { + t.Fatalf("an operator's path reads %+v", it) + } +} + +// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a +// provider that keeps its consumers' data as a cache, or in an item with no protection. +func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) { + photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"], + "data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`) + store := func(backup string) Manifest { + return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}], + "grants":{"s3-bucket":"${dir:g}"}, + "data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}], + "consumers":{"s3-bucket":{"class":"valuable","in":"data"}}}, + "resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`) + } + if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 { + t.Fatalf("a provider backing its consumers' data up was refused: %v", p) + } + if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") { + t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p) + } +} diff --git a/go/catalogue/declaration_test.go b/go/catalogue/declaration_test.go new file mode 100644 index 0000000..23b1caa --- /dev/null +++ b/go/catalogue/declaration_test.go @@ -0,0 +1,80 @@ +package catalogue + +import "testing" + +// The mesh's user list reaches the module holding the bus, and nothing else. +// +// Three refusals and one delivery, because each of the refusals would be silent in a different way: +// a module that asked and was given it could read every credential on the bus; a bus given an empty +// file refuses every connection in the mesh and looks like a machine problem; and a bus that never +// asked gets nothing rather than a file it does not read. +func TestTheMeshsUserListGoesOnlyToTheModuleHoldingTheBus(t *testing.T) { + theBus := func() Manifest { + return Manifest{ + Module: "nats", Version: "1", + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}, + BusUsers: "/var/lib/nats-module/conf/accounts.conf", + Resources: []map[string]any{}, + } + } + + on := func(t *testing.T, m Manifest, with Rendering) ([]map[string]any, error) { + t.Helper() + return Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(with) + } + + t.Run("the holder is given it", func(t *testing.T) { + resources, err := on(t, theBus(), Rendering{BusUsers: "accounts { MESH { users = [] } }"}) + if err != nil { + t.Fatal(err) + } + // Prefixed with the module it came from, like every resource: two modules may reasonably + // both call something "config", and without the prefix the second would silently replace + // the first. + var found map[string]any + for _, r := range resources { + if r["id"] == "nats."+BusUsersID() { + found = r + } + } + if found == nil { + t.Fatalf("the bus was given no user list: %+v", resources) + } + if found["path"] != "/var/lib/nats-module/conf/accounts.conf" { + t.Errorf("written to %v rather than where the module asked", found["path"]) + } + if found["mode"] != "0600" { + t.Errorf("mode %v: a list of every user in the mesh belongs to the one process that "+ + "needs it", found["mode"]) + } + }) + + t.Run("a module that does not claim the seat is refused", func(t *testing.T) { + m := theBus() + m.Claims = nil + if _, err := on(t, m, Rendering{BusUsers: "accounts {}"}); err == nil { + t.Fatal("a module that claims nothing was handed every user's password hash") + } + }) + + t.Run("the holder with nothing composed is refused", func(t *testing.T) { + if _, err := on(t, theBus(), Rendering{}); err == nil { + t.Fatal("the bus was given an empty user list, so it would refuse every connection in " + + "the mesh and look like a machine problem") + } + }) + + t.Run("a module that did not ask gets nothing", func(t *testing.T) { + m := theBus() + m.BusUsers = "" + resources, err := on(t, m, Rendering{BusUsers: "accounts {}"}) + if err != nil { + t.Fatal(err) + } + for _, r := range resources { + if r["id"] == "nats."+BusUsersID() { + t.Fatal("a module that asked for no user list was given one") + } + } + }) +} diff --git a/go/catalogue/delivery_seat_test.go b/go/catalogue/delivery_seat_test.go new file mode 100644 index 0000000..7d3a619 --- /dev/null +++ b/go/catalogue/delivery_seat_test.go @@ -0,0 +1,164 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own, +// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit. +func TestTheDeliverySeatPromisesChecks(t *testing.T) { + seat, ok := SeatNamed(DeliverySeat) + if !ok { + t.Fatal("the delivery seat is not in the set") + } + var checks *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == "checks" { + checks = &seat.Serves[i] + } + } + if checks == nil { + t.Fatalf("the delivery seat promises %v and not checks", VerbNames(seat.Serves)) + } + props, _ := checks.Input["properties"].(map[string]any) + for _, arg := range []string{"repository", "number", "commit"} { + if _, has := props[arg]; !has { + t.Errorf("checks takes no %q", arg) + } + } + if req, _ := checks.Input["required"].([]string); !reflect.DeepEqual(req, []string{"repository"}) { + t.Errorf("checks requires %v, wanted only the repository", req) + } + + // Added after the seat's holder shipped, it is optional: the holder serving the ten verbs before it still + // holds the seat, and one serving all eleven does too — so the controller and the catalogue can move in + // either order, and no check of the catalogue fails on a module nobody touched between the two. + if !checks.Optional { + t.Fatal("checks is a condition of holding before its holder serves it") + } + var before []string + for _, v := range VerbNames(seat.Serves) { + if v != "checks" { + before = append(before, v) + } + } + m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: before}}} + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder without checks yet: %v", err) + } + m.Claims[0].Serves = VerbNames(seat.Serves) + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder serving every verb: %v", err) + } + // Every other verb is still a condition of holding. + m.Claims[0].Serves = append([]string{"checks"}, before[1:]...) + if err := CanHold(m, seat); err == nil || !strings.Contains(err.Error(), before[0]) { + t.Fatalf("a holder without %s: %v", before[0], err) + } +} + +// The seat retires history on a person's word (novox/hq issue 313): `retire-history`, with why, one delivery by +// id or every held one, dry to see first. Added after the holder shipped, it is optional, so the holder that +// does not serve it yet still holds the seat, and one that does is not refused for a verb the seat lacks. +func TestTheDeliverySeatPromisesRetireHistoryOptionally(t *testing.T) { + seat, _ := SeatNamed(DeliverySeat) + var retire *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == "retire-history" { + retire = &seat.Serves[i] + } + } + if retire == nil || !retire.Optional { + t.Fatalf("the delivery seat promises %v, retire-history optionally", VerbNames(seat.Serves)) + } + props, _ := retire.Input["properties"].(map[string]any) + for _, arg := range []string{"why", "id", "repository", "dry"} { + if _, has := props[arg]; !has { + t.Errorf("retire-history takes no %q", arg) + } + } + if req, _ := retire.Input["required"].([]string); !reflect.DeepEqual(req, []string{"why"}) { + t.Errorf("retire-history requires %v, wanted only why", req) + } + var without []string + for _, v := range VerbNames(seat.Serves) { + if v != "retire-history" { + without = append(without, v) + } + } + m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}} + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder without retire-history yet: %v", err) + } + m.Claims[0].Serves = VerbNames(seat.Serves) + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder serving retire-history: %v", err) + } +} + +// The seat says delivery times (novox/hq ADR 0282, issue 382): `times`, over some days or for one delivery. Added +// after the holder shipped, it is optional, so the holder that does not serve it yet still holds the seat, and one +// that does is not refused for a verb the seat lacks. +func TestTheDeliverySeatPromisesTimesOptionally(t *testing.T) { + seat, _ := SeatNamed(DeliverySeat) + var times *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == "times" { + times = &seat.Serves[i] + } + } + if times == nil || !times.Optional { + t.Fatalf("the delivery seat promises %v, times optionally", VerbNames(seat.Serves)) + } + props, _ := times.Input["properties"].(map[string]any) + for _, arg := range []string{"days", "id"} { + if _, has := props[arg]; !has { + t.Errorf("times takes no %q", arg) + } + } + var without []string + for _, v := range VerbNames(seat.Serves) { + if v != "times" { + without = append(without, v) + } + } + m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}} + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder without times yet: %v", err) + } + m.Claims[0].Serves = VerbNames(seat.Serves) + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder serving times: %v", err) + } +} + +// The seat still promises `mark-ready`, optionally, for one more release (issue 1022); it was added by novox/hq issue 913: `mark-ready`, with the delivery's id, who reviewed it +// and why, so a reviewer marks a pull request through one audited verb, label and comment in the order the forge +// counts, instead of two raw forge calls. Added after the holder shipped, it is optional. +func TestTheDeliverySeatPromisesMarkReadyOptionally(t *testing.T) { + seat, _ := SeatNamed(DeliverySeat) + var mark *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == "mark-ready" { + mark = &seat.Serves[i] + } + } + if mark == nil || !mark.Optional { + t.Fatalf("the delivery seat promises %v, mark-ready optionally", VerbNames(seat.Serves)) + } + if req, _ := mark.Input["required"].([]string); !reflect.DeepEqual(req, []string{"id", "reviewed-by", "why"}) { + t.Errorf("mark-ready requires %v, wanted id, reviewed-by and why", req) + } + var without []string + for _, v := range VerbNames(seat.Serves) { + if v != "mark-ready" { + without = append(without, v) + } + } + m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}} + if err := CanHold(m, seat); err != nil { + t.Fatalf("a holder without mark-ready yet: %v", err) + } +} diff --git a/go/catalogue/derived_for_consumer_test.go b/go/catalogue/derived_for_consumer_test.go new file mode 100644 index 0000000..8d5e973 --- /dev/null +++ b/go/catalogue/derived_for_consumer_test.go @@ -0,0 +1,346 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// What a provider derives for each consumer, said once and delivered to both ends +// (novox/hq ADR 0202, issue 124). +// +// The failure these are written against: the object store's provisioner derived each consumer's +// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the +// consumer which bucket that was — so all three consumers wrote the answer into their own +// definitions by hand. Two were right. One named a predecessor's bucket and would have +// authenticated successfully and been refused on every object. Each of them also named the +// machine the module happens to run on, which a definition may not do. + +// store is an object store in the shape minio has: it serves a region and a port to everyone, and +// a bucket named for whoever is asking. +func store() Manifest { + return Manifest{ + Module: "store", Version: "1", + Provides: FromAnywhere("s3-bucket"), + Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}}, + Serves: map[string]map[string]any{"s3-bucket": { + "region": "eu-west", + "bucket": "${consumer:as:dns}", + }}, + Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"}, + Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "store", "ports": []any{"9000"}, + }}, + } +} + +// files is a consumer that writes the bucket into its own configuration — which is the thing it +// could not do before, and had to transcribe. +func files() Manifest { + return Manifest{ + Module: "files", Version: "1", Slug: "files", + Requires: []string{"s3-bucket"}, + Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"}, + Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"}, + Resources: []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + "content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n", + }}, + } +} + +// pics is a second consumer of the same provider on the same machine: two derivations, neither +// the other's. +func pics() Manifest { + return Manifest{ + Module: "pics", Version: "1", Slug: "pics", + Requires: []string{"s3-bucket"}, + Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"}, + Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"}, + Resources: []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600", + "content": "BUCKET=${bound:s3-bucket:bucket}\n", + }}, + } +} + +// The three places the derived value lands must agree, because agreeing is the whole point: the +// consumer's own file, the binding it reads as JSON, and the provider's contributions entry. +func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) { + r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{Grants: []Grant{{ + Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}) + if err != nil { + t.Fatal(err) + } + + // The mesh minted this identity for the consumer; the bucket is that identity as a DNS label. + // Derived here with the mesh's own function, so the test cannot agree with a wrong rule. + as := ConsumerIdentity("workstation", IdentitySource("files", "files")) + want := strings.ReplaceAll(as, "_", "-") + if want == as || !strings.Contains(as, "_") { + t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as) + } + + env := fileNamed(out, "files.env") + if env == nil { + t.Fatalf("the consumer was given no file: %v", out) + } + if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") { + t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want) + } + + binding := fileNamed(out, "files.bound-s3-bucket") + if binding == nil { + t.Fatalf("the consumer was given no binding: %v", out) + } + var said struct { + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil { + t.Fatal(err) + } + if said.Serves["bucket"] != want { + t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want) + } + // And what is the same for everybody is still the same for everybody. + if said.Serves["region"] != "eu-west" { + t.Errorf("the binding lost what the provider serves to all: %v", said.Serves) + } + + given := storeGrants(t, out) + if len(given) != 1 { + t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given) + } + if given[0].Derived["bucket"] != want { + t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+ + "the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want) + } + // Only the per-consumer half. The region is the same for everyone and is already in the + // provider's own definition; repeating it here would be a copy to go stale. + if _, carried := given[0].Derived["region"]; carried { + t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived) + } +} + +// Two consumers of one provider on one machine get two buckets, and neither gets the other's. +func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) { + r, err := Resolve(shelf(store(), files(), pics()), + []string{"store", "files", "pics"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{Grants: []Grant{ + {Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk"}, + {Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics", + Values: map[string]any{}, Sealed: "c2VhbGVk"}, + }}) + if err != nil { + t.Fatal(err) + } + forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-") + forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-") + if forFiles == forPics { + t.Fatal("the two consumers were given the same identity; this test proves nothing") + } + if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") { + t.Errorf("files was not given its own bucket:\n%s", got) + } + if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") { + t.Errorf("pics was not given its own bucket:\n%s", got) + } + var buckets []any + for _, g := range storeGrants(t, out) { + buckets = append(buckets, g.Derived["bucket"]) + } + if len(buckets) != 2 || buckets[0] == buckets[1] { + t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets) + } +} + +// An operator may still set what the provider serves where its definition asks (${setting:…}), and +// the mesh still derives the rest: the setting is filled first, then the consumer's half. (Until +// novox/hq issue 696 the layer replaced the served bucket whole; a layer replaces no served key now.) +func TestASettingComposesWithADerivedValue(t *testing.T) { + prefixed := store() + prefixed.Serves["s3-bucket"]["bucket"] = "${setting:bucket-prefix}${consumer:as:dns}" + r, err := Resolve(shelf(prefixed, files()), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{ + Settings: SettingsBy{"store": {{From: "the operator", + Values: map[string]any{"bucket-prefix": "team-"}}}}, + Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk"}}, + }) + if err != nil { + t.Fatal(err) + } + want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-") + if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") { + t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want) + } + if given := storeGrants(t, out); given[0].Derived["bucket"] != want { + t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want) + } +} + +// A fact or an alphabet the mesh does not have is refused where the definition is, not where a +// consumer happens to be resolved — and the refusal says what may be said instead. +func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) { + for _, c := range []struct{ value, says string }{ + {"${consumer:node}", "as"}, + {"${consumer:as:punycode}", "dns"}, + } { + m := store() + m.Serves["s3-bucket"]["bucket"] = c.value + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + if err == nil { + t.Fatalf("%s was accepted", c.value) + } + if !strings.Contains(err.Error(), c.value) { + t.Errorf("the refusal of %s does not quote it: %v", c.value, err) + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err) + } + } +} + +// `dns` is checked against an identity the mesh actually mints, not an invented string. +func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) { + as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud")) + if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil { + t.Fatalf("the mesh would not mint this identity at all: %v", err) + } + label := asDNSLabel(as) + if strings.Contains(label, "_") { + t.Errorf("%q is not a DNS label", label) + } + if strings.ReplaceAll(label, "-", "_") != as { + t.Errorf("%q is not %q with its separator rewritten", label, as) + } +} + +// The check that would have caught the one wrong instance: a consumer that writes the derived +// value into its own definition instead of asking for it is refused, whether it transcribed the +// right answer or a predecessor's. +func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) { + as := ConsumerIdentity("workstation", IdentitySource("files", "files")) + transcribed := strings.ReplaceAll(as, "_", "-") + + m := files() + m.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + // Exactly what the provider will create — correct today, and a copy of a rule that is + // not this module's. + "content": "BUCKET=" + transcribed + "\n", + }} + r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + _, err = r.Declaration(Rendering{Grants: []Grant{{ + Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}) + if err == nil { + t.Fatal("a definition holding its own copy of the provider's naming rule was accepted") + } + if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") { + t.Errorf("the refusal does not say what to write instead: %v", err) + } + + // And a constant the provider serves to everyone is not a transcription: repeating it is + // redundant, not wrong, and refusing it would be the mesh policing style. + m.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + "content": "REGION=eu-west\n", + }} + r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + if _, err := r.Declaration(Rendering{Grants: []Grant{{ + Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}); err != nil { + t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err) + } +} + +func storeGrants(t *testing.T, out []map[string]any) []Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/var/lib/store/grants/mesh.json" { + continue + } + var parsed struct { + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("the provider was given no contributions file: %v", out) + return nil +} + +// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing +// while its provider is told another. +// +// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own +// login — `mesh_node_mod_` (ADR 0094) — and the provider derives from the login, so it +// would make one resource per holder. The consumer's side has no such dimension: there is one +// binding file per provision and one `${bound::}`, both derived from the +// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be +// configured against `…-mod`: it would authenticate successfully and be refused on every object, +// which is exactly the fault this whole record exists to end. +// +// Refused, loudly, at the one place that can see both halves. Lifting it means giving the +// consumer's side a local dimension, which is a decision and not an omission. +func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) { + m := files() + // Two holders of the one provision, the shape ADR 0094 gives a module that keeps several. + m.Secrets = nil + m.SecretsMany = map[string]map[string]string{"s3-bucket": { + "hot": "/var/lib/files/hot.secret", + "cold": "/var/lib/files/cold.secret", + }} + m.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + "content": "BUCKET=${bound:s3-bucket:bucket}\n", + }} + r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + _, err = r.Declaration(Rendering{Grants: []Grant{ + {Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"}, + {Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"}, + }}) + if err == nil { + t.Fatal("a consumer with several holders of a deriving provider was accepted; " + + "its two ends would have disagreed in silence") + } + for _, want := range []string{"files", "s3-bucket", "bucket"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } +} diff --git a/go/catalogue/dir_into_test.go b/go/catalogue/dir_into_test.go new file mode 100644 index 0000000..ee7a96e --- /dev/null +++ b/go/catalogue/dir_into_test.go @@ -0,0 +1,325 @@ +package catalogue + +// A directory the mesh places (novox/hq ADR 0112). These tests pin the contract: a pathless +// directory resolves under the node's root, ${dir:…} names it from every field a host path can +// live in, a stated path is the adopted-data placement and wins, an unknown reference refuses at +// the manifest, and filling for one node never leaks into the next composition. + +import ( + "strings" + "testing" +) + +func placedModule() Manifest { + return Manifest{ + Module: "photos", + Resources: []map[string]any{ + {"id": "data", "type": "directory", "mode": "0700"}, + {"id": "server-env", "type": "file", "path": "${dir:data}/server.env", + "content": "STORE=${dir:data}/objects\n"}, + {"id": "server", "type": "container", "name": "photos-server", + "volumes": []any{"${dir:data}:/data"}, + "env": map[string]any{"DATA": "${dir:data}/objects"}, + "env-file": []any{"${dir:data}/server.env"}}, + }, + } +} + +func TestAPathlessDirectoryResolvesUnderTheNodesRoot(t *testing.T) { + m := placedModule() + dirs := dirsFor(m, Rendering{}) + if dirs["data"] != "/var/lib/photos/data" { + t.Fatalf("the default root is /var/lib and the shape is //; got %q", dirs["data"]) + } + dirs = dirsFor(m, Rendering{DataRoot: "/tank/nox/"}) + if dirs["data"] != "/tank/nox/photos/data" { + t.Fatalf("a node's own root is honoured, trailing slash and all; got %q", dirs["data"]) + } +} + +func TestDirReferencesBecomeThePlaceInEveryField(t *testing.T) { + m := placedModule() + dirs := dirsFor(m, Rendering{}) + + directory := shallowCopy(m.Resources[0]) + if err := dirInto(directory, dirs, m.Module); err != nil { + t.Fatal(err) + } + if directory["path"] != "/var/lib/photos/data" { + t.Fatalf("a pathless directory receives its resolved path; got %v", directory["path"]) + } + + file := shallowCopy(m.Resources[1]) + if err := dirInto(file, dirs, m.Module); err != nil { + t.Fatal(err) + } + if file["path"] != "/var/lib/photos/data/server.env" { + t.Fatalf("a file's path names the place; got %v", file["path"]) + } + if file["content"] != "STORE=/var/lib/photos/data/objects\n" { + t.Fatalf("a file's content names the place; got %v", file["content"]) + } + + container := shallowCopy(m.Resources[2]) + if err := dirInto(container, dirs, m.Module); err != nil { + t.Fatal(err) + } + if container["volumes"].([]any)[0] != "/var/lib/photos/data:/data" { + t.Fatalf("a mount names the place; got %v", container["volumes"]) + } + if container["env"].(map[string]any)["DATA"] != "/var/lib/photos/data/objects" { + t.Fatalf("an environment value names the place; got %v", container["env"]) + } + if container["env-file"].([]any)[0] != "/var/lib/photos/data/server.env" { + t.Fatalf("an env-file names the place; got %v", container["env-file"]) + } +} + +func TestAStatedPathIsThePlacementAndStillAnswersByName(t *testing.T) { + m := placedModule() + // The adopted-machine case: data that must sit where the predecessor already put it. + m.Resources[0]["path"] = "/services/mssql/data/" + dirs := dirsFor(m, Rendering{}) + if dirs["data"] != "/services/mssql/data" { + t.Fatalf("a stated path wins over the root, trimmed; got %q", dirs["data"]) + } + container := shallowCopy(m.Resources[2]) + if err := dirInto(container, dirs, m.Module); err != nil { + t.Fatal(err) + } + if container["volumes"].([]any)[0] != "/services/mssql/data:/data" { + t.Fatalf("references follow the placement; got %v", container["volumes"]) + } +} + +func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) { + m := placedModule() + first := shallowCopy(m.Resources[2]) + if err := dirInto(first, dirsFor(m, Rendering{DataRoot: "/first"}), m.Module); err != nil { + t.Fatal(err) + } + second := shallowCopy(m.Resources[2]) + if err := dirInto(second, dirsFor(m, Rendering{DataRoot: "/second"}), m.Module); err != nil { + t.Fatal(err) + } + if got := second["volumes"].([]any)[0]; got != "/second/photos/data:/data" { + t.Fatalf("the second composition must see the manifest, not the first fill; got %v", got) + } + if m.Resources[2]["volumes"].([]any)[0] != "${dir:data}:/data" { + t.Fatalf("the manifest itself stays a template; got %v", m.Resources[2]["volumes"]) + } +} + +func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { + m := placedModule() + m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape + problems := m.unknownDirRefs() + if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) { + t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems) + } + if got := placedModule().unknownDirRefs(); len(got) != 0 { + t.Fatalf("a correct definition has none; got %v", got) + } +} + +func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) { + m := placedModule() + container := shallowCopy(m.Resources[2]) + container["env"] = map[string]any{"DATA": "${dir:date}"} + err := dirInto(container, dirsFor(m, Rendering{}), m.Module) + if err == nil || !strings.Contains(err.Error(), `"date"`) || !strings.Contains(err.Error(), `"data"`) { + t.Fatalf("the refusal names the mistake and what exists; got %v", err) + } +} + +func TestTheAssignmentsOwnRootIsAPlace(t *testing.T) { + m := Manifest{Module: "mailu", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "data-mail", "type": "directory"}, + }} + dirs := dirsFor(m, Rendering{}) + if dirs["state"] != "/var/lib/mailu" { + t.Fatalf("place %q is the assignment's root; got %q", ".", dirs["state"]) + } + if dirs["data-mail"] != "/var/lib/mailu/data-mail" { + t.Fatalf("everything else sits beneath it; got %q", dirs["data-mail"]) + } + root := shallowCopy(m.Resources[0]) + if err := dirInto(root, dirs, m.Module); err != nil { + t.Fatal(err) + } + if root["path"] != "/var/lib/mailu" { + t.Fatalf("the root receives its path; got %v", root["path"]) + } + if _, still := root["place"]; still { + t.Fatal("place must never reach the host, which parses strictly") + } +} + +func TestTheManifestsMapsArePlaced(t *testing.T) { + m := Manifest{ + Module: "photos", + Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "."}, + }, + Binds: map[string]string{"route": "${dir:state}/route.json"}, + Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"}, + OwnSecrets: OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}}, + Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"}, + } + placed, err := placedManifest(m, Rendering{}) + if err != nil { + t.Fatal(err) + } + if placed.Binds["route"] != "/var/lib/photos/route.json" { + t.Fatalf("binds are placed; got %v", placed.Binds) + } + if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" { + t.Fatalf("secrets are placed; got %v", placed.Secrets) + } + if placed.OwnSecrets["admin-key"].Path != "/var/lib/photos/admin-key.secret" { + t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets) + } + if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" { + t.Fatalf("receives are placed; got %v", placed.Receives) + } + if m.Binds["route"] != "${dir:state}/route.json" { + t.Fatalf("the manifest itself stays a template; got %v", m.Binds) + } +} + +func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { + m := Manifest{ + Module: "photos", + Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}}, + Binds: map[string]string{"route": "${dir:stat}/route.json"}, + } + problems := m.unknownDirRefs() + if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) { + t.Fatalf("a map naming no directory is a manifest problem; got %v", problems) + } +} + +func TestPlaceIsValidatedAtTheManifest(t *testing.T) { + both := Manifest{Module: "x", Resources: []map[string]any{ + {"id": "d", "type": "directory", "place": ".", "path": "/somewhere"}, + }} + if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") { + t.Fatalf("path beside place refuses; got %v", got) + } + elsewhere := Manifest{Module: "x", Resources: []map[string]any{ + {"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""}, + }} + if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") { + t.Fatalf("place on a file refuses; got %v", got) + } + wrong := Manifest{Module: "x", Resources: []map[string]any{ + {"id": "d", "type": "directory", "place": "sub/dir"}, + }} + if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) { + t.Fatalf("a place that is neither root refuses; got %v", got) + } +} + +func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { + // novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings — + // is the mesh's plumbing under /mesh/, and the definition names it by id. + m := Manifest{Module: "umami", + Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "place": "mesh"}, + {"id": "state", "type": "directory", "place": "."}, + {"id": "server", "type": "container", "image": "x@sha256:aa", + "volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}}, + }, + OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}}, + Binds: map[string]string{"route": "${dir:state}/route.json"}, + } + if got := m.unknownDirRefs(); len(got) != 0 { + t.Fatalf("place %q is a place; got %v", "mesh", got) + } + dirs := dirsFor(m, Rendering{}) + if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" { + t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs) + } + dirs = dirsFor(m, Rendering{DataRoot: "/srv"}) + if dirs["mesh-state"] != "/srv/mesh/umami" { + t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs) + } + placed, err := placedManifest(m, Rendering{}) + if err != nil { + t.Fatal(err) + } + if placed.OwnSecrets["broker"].Path != "/var/lib/mesh/umami/broker" { + t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets) + } + container := shallowCopy(m.Resources[2]) + if err := dirInto(container, dirsFor(m, Rendering{}), m.Module); err != nil { + t.Fatal(err) + } + if container["volumes"].([]any)[0] != "/var/lib/mesh/umami/broker:/run/secrets/broker:ro" { + t.Fatalf("the mount's host side is placed; got %v", container["volumes"]) + } +} + +func TestTwoModulesPlacedRootsAreNoCollision(t *testing.T) { + a := Manifest{Module: "gitea", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "."}, + {"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""}, + }} + b := Manifest{Module: "nextcloud", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "."}, + {"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""}, + }} + if got := checkResources([]Manifest{a, b}); len(got) != 0 { + t.Fatalf("alike templates are different places; got %v", got) + } + // And the real collision is still caught: a module stating another's placed root. + c := Manifest{Module: "squatter", Resources: []map[string]any{ + {"id": "nest", "type": "directory", "path": "/var/lib/gitea"}, + }} + got := checkResources([]Manifest{a, c}) + if len(got) != 1 || !strings.Contains(got[0], `"/var/lib/gitea"`) { + t.Fatalf("a stated path on a placed root collides; got %v", got) + } +} + +func shallowCopy(resource map[string]any) map[string]any { + copied := map[string]any{} + for k, v := range resource { + copied[k] = v + } + return copied +} + +func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) { + // An adopted layout keeps a subdirectory the predecessor made under the mesh's directory + // (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one, + // it moves with it — a node's root moves both, and the definition names no host path. + m := Manifest{Module: "gitea", Resources: []map[string]any{ + {"id": "mesh-state", "type": "directory", "place": "mesh"}, + {"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"}, + {"id": "server", "type": "container", "image": "x@sha256:aa", + "volumes": []any{"${dir:runtime-state}:/data"}}, + }} + if got := m.unknownDirRefs(); len(got) != 0 { + t.Fatalf("a path beneath a placed directory is well formed; got %v", got) + } + dirs := dirsFor(m, Rendering{DataRoot: "/srv"}) + if dirs["runtime-state"] != "/srv/mesh/gitea/state" { + t.Fatalf("the subdirectory follows the placed one; got %v", dirs) + } + sub := shallowCopy(m.Resources[1]) + if err := dirInto(sub, dirs, m.Module); err != nil { + t.Fatal(err) + } + if sub["path"] != "/srv/mesh/gitea/state" { + t.Fatalf("the directory resource itself is resolved; got %v", sub["path"]) + } + container := shallowCopy(m.Resources[2]) + if err := dirInto(container, dirs, m.Module); err != nil { + t.Fatal(err) + } + if container["volumes"].([]any)[0] != "/srv/mesh/gitea/state:/data" { + t.Fatalf("a reference to the subdirectory resolves whole; got %v", container["volumes"]) + } +} diff --git a/go/catalogue/domain_test.go b/go/catalogue/domain_test.go new file mode 100644 index 0000000..967985b --- /dev/null +++ b/go/catalogue/domain_test.go @@ -0,0 +1,134 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A domain module is a module with requirements and no files of its own. +// +// Most people want the network working and do not want to choose a VPN. Some want a particular +// one. Both are the same mechanism: assigning `networking` takes the only answer to each of its +// requirements silently, and the day there are two answers the resolver refuses and names them, +// so choosing is assigning the one you want. There is no flavor field and nothing to configure. + +func networkingShelf(extra ...Manifest) map[string]Manifest { + base := []Manifest{ + {Module: "networking", Requires: []string{"private-network", "name-resolution"}}, + {Module: "mesh-wireguard", Computed: "mesh-wireguard", + Provides: Offers("private-network", "mesh-addressing"), + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, + {Module: "mesh-names", Computed: "mesh-names", + Provides: Offers("name-resolution"), Requires: []string{"mesh-addressing"}}, + } + return shelf(append(base, extra...)...) +} + +func TestOneWordBringsUpTheNetwork(t *testing.T) { + // The case that has to stay easy. Nothing is asked, because with one answer to each + // requirement there was never a question. + got, err := Resolve(networkingShelf(), []string{"networking"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + have := strings.Join(names(got), " ") + for _, want := range []string{"networking", "mesh-wireguard", "mesh-names"} { + if !strings.Contains(have, want) { + t.Fatalf("assigning networking did not bring in %s: %s", want, have) + } + } +} + +func TestASecondVPNTurnsItIntoAChoice(t *testing.T) { + // And the choice is offered rather than made. A default here would be the flavor field coming + // back under another name. + _, err := Resolve(networkingShelf( + Manifest{Module: "tailscale", Provides: Offers("private-network"), + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, + ), []string{"networking"}, workstation(), World{}) + + if err == nil { + t.Fatal("two VPNs and one was picked silently") + } + for _, want := range []string{"mesh-wireguard", "tailscale"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestChoosingIsAssigning(t *testing.T) { + // No second verb. Assigning the one you want answers the requirement, and the bundle takes it. + got, err := Resolve(networkingShelf( + Manifest{Module: "tailscale", + Provides: Offers("private-network", "name-resolution"), + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, + ), []string{"networking", "tailscale"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + have := strings.Join(names(got), " ") + if !strings.Contains(have, "tailscale") { + t.Fatalf("the chosen VPN is not in the set: %s", have) + } + if strings.Contains(have, "mesh-wireguard") { + t.Fatalf("choosing tailscale still installed WireGuard: %s", have) + } +} + +func TestChoosingOneVPNCannotDragTheOtherBackIn(t *testing.T) { + // This happened. The person chose tailscale; the names module required the mesh's own + // addressing; only WireGuard provides that; so both were installed and nobody was told. + // + // The claim is what catches it. Providing a private network is not the singular part — a + // machine could run two VPNs for two purposes — but being *the* one the mesh runs over is. + _, err := Resolve(networkingShelf( + Manifest{Module: "tailscale", Provides: Offers("private-network"), + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, + ), []string{"networking", "tailscale"}, workstation(), World{}) + + if err == nil { + t.Fatal("a machine was given two private networks without being told") + } + if !strings.Contains(err.Error(), "the-private-network") { + t.Fatalf("the refusal does not say what collided: %v", err) + } +} + +func TestNamesNeedTheMeshsOwnAddresses(t *testing.T) { + // Names are computed from addresses the mesh handed out. Over a VPN that hands out its own, + // the mesh has nothing to write, so the names module requires the addressing rather than a + // private network in general — otherwise a machine gets a hosts file full of addresses that + // mean nothing on it. + _, err := Resolve(shelf( + Manifest{Module: "mesh-names", Computed: "mesh-names", + Provides: Offers("name-resolution"), Requires: []string{"mesh-addressing"}}, + Manifest{Module: "tailscale", Provides: Offers("private-network")}, + ), []string{"mesh-names", "tailscale"}, workstation(), World{}) + + if err == nil { + t.Fatal("the mesh's names were installed over a VPN whose addresses it does not hand out") + } + if !strings.Contains(err.Error(), "mesh-addressing") { + t.Fatalf("the refusal does not say what is missing: %v", err) + } +} + +func TestARequirementWantedTwiceIsReportedOnce(t *testing.T) { + // Two identical lines make a person hunt for the difference between them before realising + // there is none. + _, err := Resolve(shelf( + Manifest{Module: "one", Requires: []string{"shell"}}, + Manifest{Module: "two", Requires: []string{"shell"}}, + Manifest{Module: "bash", Provides: Offers("shell")}, + Manifest{Module: "zsh", Provides: Offers("shell")}, + ), []string{"one", "two"}, workstation(), World{}) + + if err == nil { + t.Fatal("two shells and one was picked silently") + } + if n := strings.Count(err.Error(), `"shell" is wanted by`); n != 1 { + t.Fatalf("the same requirement was reported %d times:\n%v", n, err) + } +} diff --git a/go/catalogue/endpoint_test.go b/go/catalogue/endpoint_test.go new file mode 100644 index 0000000..d05f040 --- /dev/null +++ b/go/catalogue/endpoint_test.go @@ -0,0 +1,205 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds. +// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because +// the client expects that number. +func aMediaServer() Manifest { + return Manifest{ + Module: "media", + Listens: []Listening{ + {Name: "web", Port: 80, From: FromMesh, Why: "the app, behind the proxy"}, + {Name: "stream", Port: 32400, From: FromEverywhere, Fixed: true, + Why: "the client dials this number; the protocol chose it"}, + }, + Contributes: map[string]map[string]any{ + "route": {"label": "media", RouteEndpoint: "web"}, + }, + // Both endpoints are published by its container, which is what lets a machine port be given + // for either: the mesh moves a port the module publishes, never one it merely listens on. + Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "media", + "ports": []any{"80", "32400"}}, + }, + } +} + +// **A route names the endpoint it serves.** A route and a listen both carried a port and nothing said +// they were the same thing; now one of them says so. +func TestARouteNamesTheEndpointItServes(t *testing.T) { + m := aMediaServer() + if port, ok := EndpointPort(m, "web"); !ok || port != 80 { + t.Fatalf("the web endpoint resolves to %d (%v), want 80", port, ok) + } + if port, ok := EndpointPort(m, "stream"); !ok || port != 32400 { + t.Fatalf("the stream endpoint resolves to %d (%v), want 32400", port, ok) + } + if _, ok := EndpointPort(m, "absent"); ok { + t.Fatal("an endpoint the module does not declare resolved to a port") + } +} + +// And the routed set is read through the name, so the endpoint the proxy serves is known without a +// reader joining two numbers. +func TestTheRoutedEndpointIsFoundByName(t *testing.T) { + routed := RoutedPorts(aMediaServer()) + if !routed[80] { + t.Fatalf("the routed endpoint was not found by name: %v", routed) + } + // And the directly-dialled one is not routed, which is what lets its reach govern its port. + if routed[32400] { + t.Fatalf("the endpoint clients dial directly reads as routed: %v", routed) + } +} + +// **Two endpoints of different shapes, configured as themselves.** The web endpoint's reach asks for +// names and leaves its port to the proxy; the stream endpoint's reach governs its port, because +// clients dial it and there is no name. +func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) { + m := aMediaServer() + settings := SettingsBy{"media": {{From: "node anchor", Values: map[string]any{ + ReachSetting: map[string]any{"80": ReachBoth, "32400": ReachPublic}, + }}}} + + r := Resolution{Node: "anchor", Modules: []Manifest{m}, + PublicDomain: "example.test", At: "anchor.internal"} + rules, err := r.Rules(Rendering{Settings: settings}) + if err != nil { + t.Fatal(err) + } + for _, rule := range rules { + switch rule.Port { + case 80: + if rule.From != FromMesh { + t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", + rule.From) + } + case 32400: + if rule.From != FromEverywhere { + t.Fatalf("the directly-dialled endpoint's port is %q, want anywhere", rule.From) + } + } + } + + // And the routed one carries both names, asked for by the same statement. + given, err := r.contributions(settings, nil, nil) + if err != nil { + t.Fatal(err) + } + var public, internal string + for _, c := range given["route"] { + public, _ = c.Values["name"].(string) + internal, _ = c.Values["internal-name"].(string) + } + if public != "media.example.test" || internal != "media.anchor.internal" { + t.Fatalf("names are %q and %q, want both", public, internal) + } +} + +// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is +// written rather than resolving to no port and serving nothing. +func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) { + m := aMediaServer() + m.Contributes["route"][RouteEndpoint] = "absent" + got := strings.Join(RouteProblems(m), "\n") + if !strings.Contains(got, "does not declare") { + t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got) + } +} + +// **Two endpoints called the same would make an assignment configure whichever was read last.** The +// point of a name is that it identifies one thing. +func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) { + m := Manifest{Module: "twice", Listens: []Listening{ + {Name: "web", Port: 80, From: FromMesh}, + {Name: "web", Port: 8080, From: FromMesh}, + }} + got := strings.Join(endpointNameProblems(m), "\n") + if !strings.Contains(got, "could mean either") { + t.Fatalf("two endpoints with one name were accepted:\n%s", got) + } +} + +// A name that is not a name is refused where it is written: it ends up in something a person types. +func TestAnEndpointNameIsHeldToItsShape(t *testing.T) { + for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} { + m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}} + if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") { + t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got) + } + } +} + +// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one +// release before anything uses it. +func TestAnUnnamedEndpointIsStillValid(t *testing.T) { + m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}} + if got := endpointNameProblems(m); len(got) != 0 { + t.Fatalf("an unnamed endpoint was refused: %v", got) + } + if got := RouteProblems(m); len(got) != 0 { + t.Fatalf("a module with no route was refused: %v", got) + } +} + +// **A route that names an endpoint still carries that endpoint's port.** +// +// Everything downstream reads the port: the provider is told where to reach the consumer, and the +// redirection that turns a declared port into the number the machine published is keyed on it. A route +// naming only its endpoint left the proxy with no port, and a proxy with no port has nothing to dial. +// +// Caught after the catalogue had already been changed to name endpoints, and before the mesh picked +// those manifests up — which is the only reason nothing broke. +func TestARouteNamingAnEndpointStillCarriesItsPort(t *testing.T) { + m := aMediaServer() + r := Resolution{Node: "anchor", Modules: []Manifest{m}, + PublicDomain: "example.test", At: "anchor.internal"} + given, err := r.contributions(nil, nil, nil) + if err != nil { + t.Fatal(err) + } + var saw bool + for _, c := range given["route"] { + saw = true + port, ok := asPort(c.Values["port"]) + if !ok { + t.Fatalf("the route carries no port, so the proxy has nothing to dial: %v", c.Values) + } + if port != 80 { + t.Fatalf("the route carries port %d, want the web endpoint's 80", port) + } + } + if !saw { + t.Fatal("the module contributed no route") + } +} + +// And the declared port, not the machine one: the redirection to where the machine published it +// happens later and is keyed on the declared number, so filling the machine port in here would be +// redirected twice or not at all. +func TestTheEndpointsDeclaredPortIsFilledInNotTheMachineOne(t *testing.T) { + m := aMediaServer() + values := map[string]any{RouteEndpoint: "web", "label": "media"} + portOfEndpoint(values, endpointPorts(m)) + if got, _ := asPort(values["port"]); got != 80 { + t.Fatalf("filled in port %d, want the declared 80", got) + } + // Then the ordinary redirection puts it where the machine published it. + moved := atMachinePort(values, m.Module, map[string]map[int]int{"media": {80: 20009}}) + if got, _ := asPort(moved["port"]); got != 20009 { + t.Fatalf("after redirection the port is %d, want the machine's 20009", got) + } +} + +// A route that repeats a port keeps it, because that is the older shape and still read. +func TestARouteThatRepeatsItsPortKeepsIt(t *testing.T) { + values := map[string]any{RouteEndpoint: "web", "port": 8080} + portOfEndpoint(values, map[string]int{"web": 80}) + if got, _ := asPort(values["port"]); got != 8080 { + t.Fatalf("the port it stated was overwritten with %d", got) + } +} diff --git a/go/catalogue/endpoints_setting_test.go b/go/catalogue/endpoints_setting_test.go new file mode 100644 index 0000000..bfab88c --- /dev/null +++ b/go/catalogue/endpoints_setting_test.go @@ -0,0 +1,140 @@ +package catalogue + +import ( + "strings" + "testing" +) + +func configured(block map[string]any) SettingsBy { + return SettingsBy{"media": {{From: "node anchor", + Placement: map[string]any{EndpointsSetting: block}}}} +} + +// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach +// were `ports`, the route's label and `reach`, each keyed by a port number, so configuring a module +// with two endpoints of different shapes meant knowing which number was which. +func TestAnEndpointsBlockSaysPortLabelAndReach(t *testing.T) { + m := aMediaServer() + // stream's reach NARROWS what the manifest says — the manifest has it from anywhere, the + // assignment says internal. Chosen deliberately: a reach that agrees with the manifest proves + // nothing about whether the block was read at all. + settings := configured(map[string]any{ + "web": map[string]any{"port": 20009, "label": "cinema", "reach": ReachBoth}, + "stream": map[string]any{"reach": ReachInternal}, + }) + + // The machine port, where the mapping is read. + given, err := GivenPorts(m, settings["media"]) + if err != nil { + t.Fatal(err) + } + if given[80] != 20009 { + t.Fatalf("the web endpoint is on machine port %d, want 20009: %v", given[80], given) + } + + // The reach, where the filter reads it. + r := Resolution{Node: "anchor", Modules: []Manifest{m}, + PublicDomain: "example.test", At: "anchor.internal"} + rules, err := r.Rules(Rendering{Settings: settings}) + if err != nil { + t.Fatal(err) + } + for _, rule := range rules { + if rule.Port == 32400 && rule.From != FromMesh { + t.Fatalf("the directly-dialled endpoint is %q; the assignment narrowed it to the private "+ + "network and the manifest's 'anywhere' should not win", rule.From) + } + if rule.Port == 80 && rule.From != FromMesh { + t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", rule.From) + } + } + + // And the subdomain, where the name is composed — the assignment's, not the module's. + nodes, err := r.contributions(settings, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, c := range nodes["route"] { + if got, _ := c.Values["name"].(string); got != "cinema.example.test" { + t.Fatalf("the public name is %q, want the label the assignment gave", got) + } + if got, _ := c.Values["internal-name"].(string); got != "cinema.anchor.internal" { + t.Fatalf("the internal name is %q, want the label the assignment gave", got) + } + } +} + +// A block that says only a reach leaves the port to the mesh and the label to the module, which is the +// ordinary case and must not require writing the other two. +func TestABlockMaySayOnlyAReach(t *testing.T) { + m := aMediaServer() + settings := configured(map[string]any{"web": map[string]any{"reach": ReachInternal}}) + r := Resolution{Node: "anchor", Modules: []Manifest{m}, + PublicDomain: "example.test", At: "anchor.internal"} + nodes, err := r.contributions(settings, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, c := range nodes["route"] { + if got, _ := c.Values["name"].(string); got != "" { + t.Fatalf("an internal endpoint composed the public name %q", got) + } + // The module's own label, untouched. + if got, _ := c.Values["internal-name"].(string); got != "media.anchor.internal" { + t.Fatalf("the internal name is %q, want the module's own label", got) + } + } +} + +// An endpoint the module does not declare reaches nothing, and the refusal says what it does declare. +func TestConfiguringAnEndpointTheModuleLacksIsRefused(t *testing.T) { + _, err := Endpoints(aMediaServer(), configured(map[string]any{ + "admin": map[string]any{"reach": ReachInternal}})["media"]) + if err == nil || !strings.Contains(err.Error(), "does not declare") { + t.Fatalf("configuring an absent endpoint was accepted: %v", err) + } + if err != nil && !strings.Contains(err.Error(), "stream") { + t.Fatalf("the refusal does not name what the module declares: %v", err) + } +} + +func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) { + _, err := Endpoints(aMediaServer(), configured(map[string]any{ + "web": map[string]any{"reach": "mesh"}})["media"]) + if err == nil || !strings.Contains(err.Error(), "a reach is") { + t.Fatalf("a filter word was accepted as a reach: %v", err) + } +} + +// **Two places giving one endpoint a machine port is the confusion this key exists to end.** +func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) { + m := aMediaServer() + _, err := GivenPorts(m, []Layer{{From: "node anchor", Placement: map[string]any{ + EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}}, + PortsSetting: map[string]any{"80": 30000}, + }}}) + if err == nil || !strings.Contains(err.Error(), "published once") { + t.Fatalf("an endpoint given two machine ports was accepted: %v", err) + } +} + +// And the same for its reach, said once here and once through the older key. +func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) { + _, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Placement: map[string]any{ + EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}}, + ExposeSetting: map[string]any{"80": FromEverywhere}, + }}}) + if err == nil || !strings.Contains(err.Error(), "same thing in different words") { + t.Fatalf("a reach said two ways was accepted: %v", err) + } +} + +// A module whose endpoints are unnamed cannot be configured this way, and is told so rather than +// having a block silently reach nothing — which is every module in the catalogue today. +func TestAModuleWithNoNamedEndpointsIsToldSo(t *testing.T) { + m := Manifest{Module: "media", Listens: []Listening{{Port: 80, From: FromMesh}}} + _, err := Endpoints(m, configured(map[string]any{"web": map[string]any{"reach": ReachBoth}})["media"]) + if err == nil || !strings.Contains(err.Error(), "no endpoints by name") { + t.Fatalf("a module with no named endpoints accepted a block: %v", err) + } +} diff --git a/go/catalogue/environment_into_integration_test.go b/go/catalogue/environment_into_integration_test.go new file mode 100644 index 0000000..b41864e --- /dev/null +++ b/go/catalogue/environment_into_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package catalogue + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh on this machine") + } + script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX + + `[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" + + `echo "$PATH"; echo "$GOPATH"` + out, err := exec.Command(sh, "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("sourcing twice: %v\n%s", err, out) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" + if lines[0] != want { + t.Fatalf("PATH is %s, not %s", lines[0], want) + } + if lines[1] != "/home/op/go" { + t.Fatalf("GOPATH was not exported: %q", lines[1]) + } + // And an entry the account already has stays where it is, and once. + out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != + "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" { + t.Fatalf("an entry already on PATH was added again or moved: %s", got) + } +} + +func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) { + generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator" + if _, err := os.Stat(generator); err != nil { + t.Skip("NOT JUDGED: no environment.d generator on this machine, so the service manager's reading " + + "of the rendering is judged only where systemd runs; the rendering itself is held byte for byte " + + "by TestTheEnvironmentRendersForTheServiceManagerByteForByte") + } + const base = "/usr/bin:/bin" + read := func(conf string) map[string]string { + t.Helper() + config := t.TempDir() + if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil { + t.Fatal(err) + } + if conf != "" { + if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command(generator) + cmd.Env = []string{"PATH=" + base, "HOME=" + config, "XDG_CONFIG_HOME=" + config} + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + vars := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + vars[k] = v + } + } + return vars + } + machine, read50 := read(""), read(composedSystemd) + + // What the machine's own files do to PATH: nothing, or append to it. One that replaces or prepends + // leaves nothing this test can say about the mesh's file, and says so rather than guess. + added, ok := strings.CutPrefix(machine["PATH"], base) + if machine["PATH"] != "" && !ok { + t.Skipf("NOT JUDGED: this machine's own environment.d sets PATH to %s, not %s with something after it, so "+ + "what the mesh's file adds cannot be told apart from it", machine["PATH"], base) + } + want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" + added + if read50["PATH"] != want || read50["GOPATH"] != "/home/op/go" { + t.Fatalf("the service manager read PATH=%s GOPATH=%s, not PATH=%s GOPATH=/home/op/go (the machine's own "+ + "files add %q to PATH)", read50["PATH"], read50["GOPATH"], want, added) + } +} diff --git a/go/catalogue/environment_into_test.go b/go/catalogue/environment_into_test.go new file mode 100644 index 0000000..ca112e9 --- /dev/null +++ b/go/catalogue/environment_into_test.go @@ -0,0 +1,424 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module +// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder). + +// contributors is a fixed set of contributions, in no particular order: what the renderings are +// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the +// ordinary case of two modules sharing a directory, and is written once. +func contributors() []Manifest { + return []Manifest{ + {Module: "zsh", Environment: &Environment{ + Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"}, + Path: []PathEntry{ + {Entry: "${machine:account-home}/.local/bin", At: PathAtStart}, + {Entry: "${machine:account-home}/bin", At: PathAtStart}, + {Entry: "/opt/scripts", At: PathAtEnd}, + }, + }}, + {Module: "go-toolchain", Environment: &Environment{ + Variables: map[string]string{"GOPATH": "${machine:account-home}/go"}, + Path: []PathEntry{ + {Entry: "${machine:account-home}/go/bin", At: PathAtStart}, + {Entry: "/usr/local/go/bin", At: PathAtStart}, + {Entry: "${machine:account-home}/.local/bin", At: PathAtStart}, + }, + }}, + {Module: "agent", Environment: &Environment{ + Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"}, + Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}}, + }}, + // A module contributing nothing is in the set and writes nothing. + {Module: "postgres"}, + } +} + +var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"} + +// The final PATH this set composes, around whatever the account had: the start entries in module +// order and then declared order, the account's own, then the end entries. +const composedPOSIX = `# agent +export DISABLE_AUTOUPDATER='1' +# go-toolchain +export GOPATH='/home/op/go' +# zsh +export EDITOR='vim' +export XDG_CONFIG_HOME='/home/op/.config' +# zsh +case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac +# go-toolchain +case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac +# agent +case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac +# zsh +case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac +export PATH +` + +const composedSystemd = `# agent +DISABLE_AUTOUPDATER=1 +# go-toolchain +GOPATH=/home/op/go +# zsh +EDITOR=vim +XDG_CONFIG_HOME=/home/op/.config +# go-toolchain, zsh +PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH} +# agent, zsh +PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts +` + +func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) { + env, err := environmentOn(contributors(), operatorFacts) + if err != nil { + t.Fatal(err) + } + if got := env.posix(); got != composedPOSIX { + t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX) + } +} + +func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) { + env, err := environmentOn(contributors(), operatorFacts) + if err != nil { + t.Fatal(err) + } + if got := env.systemd(); got != composedSystemd { + t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd) + } +} + +// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file +// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim +// is about what a shell does with the file, not about what the file looks like. + +// The environment.d rendering, read by the service manager's own generator where this machine has +// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted. +// +// The generator also reads the machine's own environment.d (/etc, /run, /usr/lib, /usr/local/lib), and +// no option points it elsewhere: a desktop whose snapd appends its bin directory failed this, on main, +// for a file the mesh never wrote (novox/hq issue 432). So the generator is run twice, once without the +// mesh's file, and what the machine's own files make of PATH and set is held out of the verdict. +// +// A machine without the generator — the build seat's toolchain image holds no systemd — cannot judge +// this and says so: there the rendering is held byte for byte by +// TestTheEnvironmentRendersForTheServiceManagerByteForByte, and this reading only where systemd runs. + +// Nothing contributed renders nothing, in both formats — not an empty `export PATH`. +func TestNoContributionsRenderNothing(t *testing.T) { + env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts) + if err != nil { + t.Fatal(err) + } + if env.posix() != "" || env.systemd() != "" { + t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd()) + } +} + +// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is. +func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) { + _, err := environmentOn(contributors(), map[string]string{"name": "server"}) + if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") || + !strings.Contains(err.Error(), "${machine:account-home}") { + t.Fatalf("a missing account home was not refused by name: %v", err) + } +} + +// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins. +func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) { + modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{ + Variables: map[string]string{"EDITOR": "nvim"}}}) + _, err := environmentOn(modules, operatorFacts) + if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+ + "environment, so one of them must stop setting it (novox/hq ADR 0203)" { + t.Fatalf("a variable set twice was not refused naming both: %v", err) + } + // And at composition, whether or not the node holds the environment. + r := Resolution{Node: "workstation", Account: "op", Modules: modules} + if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") { + t.Fatalf("composition accepted a variable set twice: %v", err) + } +} + +// shells contributes code for several shells and slots, in no order. +func shells() []Manifest { + return []Manifest{ + {Module: "zsh-syntax-highlighting", Shell: []ShellCode{ + {For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"}, + }}, + {Module: "powerlevel10k", Shell: []ShellCode{ + {For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" + + " source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"}, + {For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"}, + {For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"}, + }}, + {Module: "zsh-autosuggestions", Shell: []ShellCode{ + {For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"}, + {For: "bash", Slot: "normal", Code: "echo not for zsh"}, + }}, + {Module: "direnv", Shell: []ShellCode{ + {For: "fish", Slot: "last", Code: "direnv hook fish | source"}, + {For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""}, + }}, + } +} + +// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the +// order it declared them under a line naming it; empty when nothing is contributed. +func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) { + if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+ + "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+ + "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+ + "# zsh-autosuggestions\n"+ + "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want { + t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want) + } + if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+ + "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want { + t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want) + } + if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want { + t.Fatalf("bash's last slot is %q, not %q", got, want) + } + if got := shellCode(shells(), "fish", "first"); got != "" { + t.Fatalf("a slot nobody contributed to holds %q", got) + } +} + +// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the +// slots — which holds `${…}` of the shell's own that no mesh pass may touch either. +func zshHolder() Manifest { + return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}, + Resources: []map[string]any{ + {"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" + + "${shell:zsh:first}" + + "PROMPT='%n@%m ${PWD/#$HOME/~} '\n" + + "${shell:zsh:normal}" + + "alias ll='ls -l'\n" + + "${shell:zsh:last}"}, + }} +} + +// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for +// byte, because the shell's code is placed after every other placeholder pass and in one pass — a +// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and +// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see. +func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) { + modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{ + {For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"}, + }}) + r := Resolution{Node: "workstation", Account: "op", Modules: modules} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + var zshrc map[string]any + for _, res := range out { + if res["id"] == "zsh.zshrc" { + zshrc = res + } + } + if zshrc == nil { + t.Fatalf("the holder's file was not composed: %v", out) + } + if zshrc["path"] != "/home/op/.zshrc" { + t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"]) + } + want := "# powerlevel10k\n" + + "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" + + " source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" + + "PROMPT='%n@%m ${PWD/#$HOME/~} '\n" + + "# powerlevel10k\n" + + "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" + + "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" + + "# zsh-autosuggestions\n" + + "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" + + "alias ll='ls -l'\n" + + "# sly\n" + + "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" + + "# zsh-syntax-highlighting\n" + + "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n" + if got := zshrc["content"]; got != want { + t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want) + } +} + +// The holder of node-environment places both renderings, and they are the same as rendered alone. +func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) { + holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}}, + Resources: []map[string]any{ + {"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh", + "content": "# The mesh's environment.\n${environment:posix}"}, + {"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", + "content": "${environment:systemd}"}, + }} + r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + by := map[string]any{} + for _, res := range out { + by[res["id"].(string)] = res["content"] + } + if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX { + t.Fatalf("the POSIX file is\n%v", by["node-env.posix"]) + } + if by["node-env.systemd"] != composedSystemd { + t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"]) + } +} + +// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser, +// which is what the catalogue check and registration run, and again at composition, in the same words. +func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, + {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, + } { + raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err) + } + m := Manifest{Module: "toolchain", Resources: []map[string]any{ + {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} + r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}} + if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("composition accepted %s outside its holder: %v", c.content, err) + } + } +} + +// A key nobody renders is refused, not left in the file as a literal. +func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"}, + {"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell::}"}, + {"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell::}"}, + {"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell::}"}, + } { + raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` + + `"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s was accepted: %v", c.content, err) + } + } + // And outside a file's content, where nothing could be placed. + raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` + + `"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || + !strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") { + t.Errorf("a placeholder in a path was accepted: %v", err) + } +} + +// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else. +func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) { + for _, c := range []struct{ environment, want string }{ + {`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`}, + {`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`}, + {`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`}, + {`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`}, + {`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`}, + {`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`}, + {`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`}, + {`{"variables":{"A":"a\\b"}}`, `which holds a backslash`}, + {`{"variables":{"A":"a\nb"}}`, `which holds a line break`}, + {`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`}, + {`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`}, + {`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`}, + {`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`}, + {`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`}, + {`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`}, + {`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`}, + {`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`}, + } { + _, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.environment, c.want, err) + } + } + // What is allowed: a literal, and the machine's own facts. + if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` + + `"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` + + `"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil { + t.Fatalf("a well-formed environment was refused: %v", err) + } +} + +func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) { + for _, c := range []struct{ shell, want string }{ + {`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`}, + {`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`}, + {`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`}, + {`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`}, + } { + _, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.shell, c.want, err) + } + } + // The code itself is never judged: a shell's own `${…}` is not the mesh's. + if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` + + `"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil { + t.Fatalf("shell code was judged as if it were the mesh's: %v", err) + } +} + +// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login +// shell's contract is `execute`, described and with a schema an agent can call. +func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { + env, ok := SeatNamed("node-environment") + if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" || + len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" { + t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok) + } + shell, ok := SeatNamed("node-login-shell") + if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" { + t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok) + } + if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" { + t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves) + } + props, _ := shell.Serves[0].Input["properties"].(map[string]any) + required, _ := shell.Serves[0].Input["required"].([]string) + if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" { + t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input) + } + if _, has := props["timeout_seconds"]; !has { + t.Fatalf("execute takes no timeout: %v", props) + } +} + +// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's +// name nor under the name a module gave it before. +func TestNoModuleMayDeclareTheLoginShell(t *testing.T) { + for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} { + raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}` + _, err := ParseManifest([]byte(raw)) + if err == nil { + t.Errorf("a module declaring %q was accepted", n) + } + } + got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh", + DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ") + if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) { + t.Fatalf("declaring login-shell was not refused by name: %q", got) + } + // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. + m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) + if err != nil { + t.Fatal(err) + } + if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil { + t.Fatalf("a shell module claiming the seat cannot hold it: %v", err) + } +} diff --git a/go/catalogue/files_test.go b/go/catalogue/files_test.go new file mode 100644 index 0000000..63f6923 --- /dev/null +++ b/go/catalogue/files_test.go @@ -0,0 +1,111 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// issue 900, novox/hq ADR 0394 §1: node-files is a node seat whose verbs say the commands they replace. +func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) { + s, ok := SeatNamed(FilesSeat) + if !ok { + t.Fatalf("the mesh defines no seat %s", FilesSeat) + } + if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 0394") { + t.Errorf("%s: scope %q, delivers %q, decision %q", FilesSeat, s.Scope, s.Delivers, s.Decision) + } + have := map[string]bool{} + replaced := map[string]string{} + for _, v := range s.Serves { + have[v.Name] = true + if v.Optional { + t.Errorf("%s.%s is optional", FilesSeat, v.Name) + } + if len(v.Replaces) == 0 { + t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name) + } + for _, c := range v.Replaces { + replaced[c] = v.Name + } + } + for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move", + "delete", "mkdir", "chmod", "chown", "trash", "restore"} { + if !have[name] { + t.Errorf("%s serves no verb %s", FilesSeat, name) + } + } + for command, verb := range map[string]string{"cat": "read", "tee": "write", "cp": "copy", "mv": "move", + "rm": "delete", "mkdir -p": "mkdir", "chmod": "chmod", "chgrp": "chown"} { + if replaced[command] != verb { + t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], FilesSeat, verb) + } + } +} + +// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as. +func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) { + p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}}) + if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") { + t.Errorf("a holder declaring runs-as was not refused: %v", p) + } + if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 { + t.Errorf("a holder declaring none was refused: %v", p) + } + if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 { + t.Errorf("a non-holder was judged: %v", p) + } +} + +// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node. +func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { + files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}}, + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}} + host := Manifest{Module: "hostnames", Version: "1", Claims: []Claim{{Name: "node-hostname"}}, + Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}} + app := Manifest{Module: "app", Version: "1", + Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}} + r := Resolution{Node: "anchor", Modules: []Manifest{files, host, app}} + words, err := r.filesWords(files, nil, Rendering{}) + if err != nil { + t.Fatal(err) + } + var places []string + if err := json.Unmarshal([]byte(words[ReadsSecretPlaces]), &places); err != nil || len(places) == 0 { + t.Errorf("no secret places: %v %v", err, words) + } + var owned map[string]string + if err := json.Unmarshal([]byte(words[FilesOwnedPaths]), &owned); err != nil { + t.Fatalf("no owned paths: %v (%v)", err, words) + } + if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") { + t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"]) + } + if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") { + t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"]) + } + if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" { + t.Error("a module not holding node-files is told the owned paths") + } +} + +// issue 900: restore, like every write verb, takes a reason and an override. +func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) { + s, _ := SeatNamed(FilesSeat) + for _, v := range s.Serves { + if v.Name != "restore" { + continue + } + props, _ := v.Input["properties"].(map[string]any) + for _, p := range []string{"id", "reason", "override"} { + if _, has := props[p]; !has { + t.Errorf("restore has no input %s", p) + } + } + if req, _ := v.Input["required"].([]string); strings.Join(req, ",") != "id,reason" { + t.Errorf("restore requires %v, want id and reason", req) + } + return + } + t.Error("no restore verb") +} diff --git a/go/catalogue/filtering_foundation_test.go b/go/catalogue/filtering_foundation_test.go new file mode 100644 index 0000000..6005651 --- /dev/null +++ b/go/catalogue/filtering_foundation_test.go @@ -0,0 +1,58 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The mesh's own ports survive a ruleset derived from modules that do not mention them. +// +// **The firewall is computed from what modules declare they listen on, and the foundation is not a +// module** (novox/hq 04-ISSUES/052). So the broker's port — the one every machine dials to enrol +// and to receive every declaration it is ever sent — was absent from every ruleset the mesh ever +// generated, and nothing caught it: a mesh of one never dials its own broker across the network, +// so the ruleset looks complete right up until a second machine tries to join and is refused by +// the packet filter, during enrolment, before the mesh can report anything about it. +func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) { + const brokerPort = 5671 + + // A machine on the private network, with one ordinary module rule, and nothing that mentions + // the broker — which is every machine. + rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}} + out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort}, nil, "mesh0") + + if !strings.Contains(out, "tcp dport 5671 accept") { + t.Fatalf("the broker's port is not opened, so no machine could enrol:\n%s", out) + } + + // From anywhere, deliberately: a node enrols BEFORE it has an address on the private network, + // so a rule narrowed to that network would close the door being knocked on. + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "5671") && strings.Contains(line, "saddr") { + t.Fatalf("the broker's port is narrowed to the private network, which a machine that "+ + "has not yet enrolled is not on:\n%s", line) + } + } + + // And forwarded, not only accepted on input: the broker is a published container port, so a + // cross-node dial is redirected and forwarded and never reaches the input chain. Without this + // a joined node reaches the broker only until its first connection's conntrack entry drops — + // which a broker restart (adopting the foundation's broker) causes — and then never receives + // another declaration (novox/hq issue 063). + if !strings.Contains(out, "ct original proto-dst 5671 accept") { + t.Fatalf("the broker's port is not forwarded, so a DNAT'd broker is unreachable "+ + "cross-node after it restarts:\n%s", out) + } +} + +// And a mesh that was never told about a broker still gets a ruleset, rather than an empty one or +// a panic. A control plane in that state cannot issue tokens either, which is where it surfaces. +func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) { + out := AsNftables(nil, []string{"10.42.0.1"}, false, nil, nil, "mesh0") + if !strings.Contains(out, "table inet mesh") { + t.Fatalf("no ruleset at all:\n%s", out) + } + if strings.Contains(out, "never derived, never closed\n\t\ttcp dport") { + t.Fatalf("a foundation rule was written for a mesh with no broker:\n%s", out) + } +} diff --git a/go/catalogue/filtering_test.go b/go/catalogue/filtering_test.go new file mode 100644 index 0000000..11cb514 --- /dev/null +++ b/go/catalogue/filtering_test.go @@ -0,0 +1,920 @@ +package catalogue + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried +// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's +// declaration parser has always refused unknown keys; manifests never did. +func TestAManifestKeyNothingReadsIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`)) + if err == nil { + t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived") + } + if !strings.Contains(err.Error(), "scope") { + t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err) + } +} + +// The same discipline must not refuse a manifest that is merely ordinary. +func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) { + m, err := ParseManifest([]byte( + `{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`)) + if err != nil { + t.Fatalf("an ordinary manifest was refused: %v", err) + } + if len(m.Listens) != 1 || m.Listens[0].At() != "tcp" { + t.Fatalf("listens did not survive parsing: %+v", m.Listens) + } +} + +// A rule with no source is open, and must say so rather than appear to restrict something. +func TestAPortWithNoSourceIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`)) + if err == nil { + t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction") + } + if !strings.Contains(err.Error(), "from where") { + t.Fatalf("refused without saying what is missing: %v", err) + } +} + +func TestASourceNobodyDefinedIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`)) + if err == nil { + t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped") + } +} + +// The derivation is over the whole node, not one module. +func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }} + rules := mustFilter(t, r, nil) + if len(rules) != 2 { + t.Fatalf("a node's rule set lost a module's ports: %+v", rules) + } + if rules[0].Port != 443 || rules[1].Port != 5432 { + t.Fatalf("rules are not in a stable order, so every diff is unreadable: %+v", rules) + } +} + +// Every rule names what caused it, and both causes survive. +func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the site"}}}, + {Module: "board", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the board"}}}, + }} + rules := mustFilter(t, r, nil) + if len(rules) != 1 { + t.Fatalf("one port became %d rules", len(rules)) + } + if len(rules[0].Because) != 2 { + t.Fatalf("a module that wanted this port open is not named: %+v", rules[0]) + } + // The consequence, which is the reason this matters: removing web must not read as closing 443. + nft := AsNftables(rules, nil, false, nil, nil, "mesh0") + if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") { + t.Fatalf("the rendered rule set does not name both sources:\n%s", nft) + } +} + +// Widening, and only widening. +func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + {Module: "board", Listens: []Listening{{Port: 443, From: FromMesh}}}, + }} + rules := mustFilter(t, r, nil) + if len(rules) != 1 { + t.Fatalf("the same port was rendered twice, once restricting nothing: %+v", rules) + } + if rules[0].From != FromEverywhere { + t.Fatalf("the narrower rule won, which closes a port a module asked to open: %+v", rules[0]) + } + if len(rules[0].Because) != 2 { + t.Fatalf("the covered module was dropped along with its rule: %+v", rules[0]) + } +} + +// What is not declared is closed. +func TestWhatNoModuleDeclaredIsClosed(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0") + // Naming the chain, not just the policy: the forward chain drops too, and an assertion on + // "policy drop" alone passes while the input chain accepts everything. It did, once, here. + if !strings.Contains(nft, "type filter hook input priority filter; policy drop;") { + t.Fatalf("the input chain does not drop by default, so nothing is filtered:\n%s", nft) + } + // Some port nothing asked for. **Not 22**, which this used to use: ssh is now the one line in + // the chain that nothing derives, because a mesh that has been assigned almost nothing computes + // a firewall that shuts the port used to fix it (novox/hq issue 047). + if strings.Contains(nft, "dport 9999") { + t.Fatalf("a port no module declared was opened:\n%s", nft) + } + // Dropping by default must not mean unreachable: the node's own outbound link to the broker + // is the one connection the whole mesh runs on, and it is a reply to something it started. + if !strings.Contains(nft, "ct state established,related accept") { + t.Fatalf("a node applying this would lose its own link to the mesh:\n%s", nft) + } + if !strings.Contains(nft, "iif lo accept") { + t.Fatalf("loopback is filtered, which breaks everything on the machine:\n%s", nft) + } +} + +// Loading it twice must be the same as loading it once, and must not disturb anything else. +// +// `flush ruleset` would do the first and not the second: it empties every table on the machine, +// including the ones the container runtime writes for its bridges. +func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) { + nft := AsNftables(nil, nil, false, nil, nil, "mesh0") + if strings.Contains(nft, "flush ruleset") { + t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft) + } + if !strings.Contains(nft, "delete table inet mesh") { + t.Fatalf("loading it a second time would add to what is there rather than replace it:\n%s", nft) + } + // The delete must not fail on a machine that has never loaded one, or the service does not + // start and the machine filters nothing while reporting a configuration error. + if !strings.Contains(nft, "table inet mesh {}\ndelete table inet mesh") { + t.Fatalf("the delete has nothing to delete on a machine loading this for the first time:\n%s", nft) + } +} + +// The rule set governs what is forwarded too, or it does not govern container ports at all. +// +// **This reverses an earlier decision, and keeps the thing that decision was protecting.** There was +// no forward chain, on the reasoning that dropping there stops container traffic the runtime +// allowed — every container on the node, control plane included. That much is true. What it missed +// is that a published port is redirected and then forwarded, so it never reaches the input chain, +// and a firewall without a forward chain is silent about exactly the ports worth protecting +// (novox/hq issue 047). +// +// So the chain exists and denies by default, and the runtime's own networks are allowed explicitly +// — which is how the system being replaced has been doing it on these machines for months. +func TestWhatIsForwardedIsGovernedToo(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "mesh0") + if !strings.Contains(nft, "hook forward priority filter; policy drop") { + t.Fatalf("forwarded traffic is not governed, so container ports are open:\n%s", nft) + } +} + +// And this machine's own guests keep working, which is the whole reason the chain was left out +// before — by not being mentioned (novox/hq ADR 0140). +// +// It used to be done by naming the address ranges they sit on: two fixed here and the rest recorded +// per machine. That list broke a workstation's containers at a flip and could not be made correct, +// because a range describes one machine and cannot tell a network the mesh made from one a +// predecessor left behind. What replaced it is a single line about the links traffic arrives on. +func TestThisMachinesOwnGuestsKeepWorkingWithoutBeingNamed(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0") + if !strings.Contains(nft, `iifname != { "eth0", "mesh0" } accept`) { + t.Fatalf("what did not arrive from outside is not accepted, so this machine's own guests "+ + "reach nothing:\n%s", nft) + } +} + +// No address of a machine's own networks appears anywhere in a rendered filter. +// +// This is the assertion that fails against the previous behaviour, and it is why it is written on +// the text rather than on an outcome: the two ranges were a constant in this file, so nothing but +// reading the output catches one creeping back in. +func TestNoNetworkOfTheMachinesOwnIsNamed(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0") + for _, gone := range []string{"172.16.0.0/12", "192.168.128.0/17", "saddr 192.168", "saddr 172."} { + if strings.Contains(nft, gone) { + t.Fatalf("%q is named, and a range describes one machine and goes stale in silence:\n%s", + gone, nft) + } + } +} + +// **The tunnel is constrained, not treated as inside.** +// +// Accepting everything arriving over the private network would make a port nothing declares +// reachable from every machine in the mesh — the derivation abandoned, and a rule that reads as a +// restriction while restricting nothing. So the tunnel is named beside the outward links, and +// traffic arriving on it meets the declared rules like anything else. +func TestTheTunnelIsConstrainedLikeAnOutwardLink(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0") + line := `iifname != { "eth0", "mesh0" } accept` + if !strings.Contains(nft, line) { + t.Fatalf("the tunnel is not constrained, so an undeclared port is reachable from any "+ + "machine in the mesh:\n%s", nft) + } +} + +// A machine with two links facing outside has both constrained. Asserted on the one line, because a +// rule covering one and not the other would leave a machine filtering half of what reaches it. +func TestEveryOutwardLinkIsConstrained(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0", "wlan0"}, "mesh0") + if !strings.Contains(nft, `iifname != { "eth0", "wlan0", "mesh0" } accept`) { + t.Fatalf("not every outward link is constrained:\n%s", nft) + } +} + +// A guest asks its host for an address and for names, and those two arrive at the input chain. Asked +// for by the link they arrive on, so a resolver bound anywhere but an outward link keeps answering. +func TestGuestsMayAskTheirHostForAnAddressAndNames(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0") + for _, want := range []string{ + `iifname != { "eth0", "mesh0" } udp dport { 53, 67 } accept`, + `iifname != { "eth0", "mesh0" } tcp dport 53 accept`, + } { + if !strings.Contains(nft, want) { + t.Fatalf("a guest cannot ask its host for an address or a name, which is not a closed "+ + "port but a network that does not work:\n%s", nft) + } + } +} + +// With no link named at all the chain denies rather than rendering an empty set, which nftables +// refuses — and a rule set that does not load is a machine filtering nothing while its unit reports +// success. Composing a declaration for such a machine is refused upstream; this is the floor. +func TestNoLinkNamedRendersNoCatchAllRatherThanAnEmptySet(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "") + if strings.Contains(nft, "{ }") || strings.Contains(nft, "iifname != {}") { + t.Fatalf("an empty set is rendered, which nftables refuses:\n%s", nft) + } + if !strings.Contains(nft, "hook forward priority filter; policy drop") { + t.Fatalf("the forward chain does not deny:\n%s", nft) + } +} + +// A machine that has not said which links face outside is sent no filter, and the refusal names the +// module that would have loaded it so the reader knows what is being withheld. +func TestAMachineThatNamedNoOutwardLinkIsSentNoFilter(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{ + {Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}}, + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }} + _, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}, TunnelInterface: "mesh0"}) + if err == nil { + t.Fatal("a machine that named no outward link was sent a filter written around none") + } + for _, want := range []string{"anchor", "nftables", "face outside"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not say %q: %v", want, err) + } + } +} + +// And a machine that names none but loads no filter is not refused: there is nothing to write. +func TestAMachineWithNoFilterModuleIsNotRefused(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }} + if _, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}}); err != nil { + t.Fatalf("a machine that loads no filter was refused one: %v", err) + } +} + +// A published port is matched by what the client asked for, not by where the packet ends up. +// +// The runtime rewrites the destination before this chain sees it, so a rule naming the published +// port would match nothing and the port would stay shut while reporting itself open. +func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}}, + }}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0") + if !strings.Contains(nft, "ct original proto-dst 8080 accept") { + t.Fatalf("the forwarded rule does not match the port a client asked for:\n%s", nft) + } +} + +// And a mesh-scoped port stays mesh-scoped when it is reached through the runtime. +func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0") + if !strings.Contains(nft, "ip saddr { 198.51.100.2 } ct original proto-dst 5432 accept") { + t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft) + } +} + +// "From the mesh" is the addresses the mesh actually has. +func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0") + if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 5432 accept") { + t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft) + } +} + +// The case that must not be widened silently. +func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }}, nil), nil, false, nil, nil, "mesh0") + if strings.Contains(nft, "dport 5432 accept") { + t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft) + } + if !strings.Contains(nft, "closed") { + t.Fatalf("it was closed silently, which is a fault nobody can find:\n%s", nft) + } +} + +// A port bound for something else on the same machine must not reach the network. +func TestAMachineScopedPortIsNotOpened(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}}, + }}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0") + if strings.Contains(nft, "dport 6379 accept") { + t.Fatalf("a port for this machine only was opened to the network:\n%s", nft) + } +} + +// The rule set is the machine's, not the asking module's. +func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "firewall", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}}, + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }} + out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}, + OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"}) + if err != nil { + t.Fatalf("declaration: %v", err) + } + var content string + for _, res := range out { + if res["path"] == "/etc/mesh/filter.nft" { + content, _ = res["content"].(string) + } + } + if content == "" { + t.Fatal("the module that asked for the rule set did not receive one") + } + if !strings.Contains(content, "dport 443 accept") { + t.Fatalf("the rule set holds only the asking module's ports:\n%s", content) + } +} + +func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`)) + if err == nil { + t.Fatal("a module asked for the rule set and named no path; it would receive nothing") + } +} + +// nftables matches the two address families separately, and one set holding both is a syntax +// error — so the file fails to load, the service reports a fault, and the machine filters nothing. +func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil, nil, "mesh0") + if !strings.Contains(nft, "ip saddr { 198.51.100.2 } tcp dport 5432 accept") { + t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft) + } + if !strings.Contains(nft, "ip6 saddr { 2001:db8::2 } tcp dport 5432 accept") { + t.Fatalf("the machines with v6 addresses were dropped:\n%s", nft) + } + // And never in one set, which is the syntax error this exists to avoid. + if strings.Contains(nft, "198.51.100.2, 2001:db8::2") { + t.Fatalf("both families are in one set, so the file will not load:\n%s", nft) + } +} + +// The host does not sort, so the order the mesh writes is the order a machine applies. +// +// A module's service or container routinely depends on a file the mesh computed — a certificate, +// a credential, a rule set. Written after the service, the service is applied first, fails, and +// the next reconcile fixes it. A fault that repairs itself on the second attempt is worse than one +// that does not: what gets remembered is that it works. +func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) { + r := Resolution{Modules: []Manifest{{ + Module: "firewall", + Filtering: &Filtering{Into: "/etc/nftables.conf"}, + Resources: []map[string]any{ + {"id": "load", "type": "service", "unit": "nftables.service", "state": "running", + "restart-on": []any{"filtering"}}, + }, + }}} + out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}, + OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"}) + if err != nil { + t.Fatalf("declaration: %v", err) + } + var order []string + for _, res := range out { + order = append(order, fmt.Sprint(res["id"])) + } + if len(order) != 2 { + t.Fatalf("expected the rule set and the service, got %v", order) + } + if order[0] != "firewall.filtering" { + t.Fatalf("the service is applied before the file it reflects: %v", order) + } +} + +// The same, for a module whose resources are computed elsewhere — that branch replaces the +// module's resources wholesale, and merging in the wrong place would throw away its credentials. +func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) { + r := Resolution{Modules: []Manifest{{ + Module: "networking", Computed: "mesh-network", + OwnSecrets: OwnSecrets{"key": {Path: "/var/lib/mesh/key"}}, + }}} + out, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"networking": {"key": "sealed"}}, + Generators: map[string]Generator{"mesh-network": computedOnce{}}, + }) + if err != nil { + t.Fatalf("declaration: %v", err) + } + var ids []string + for _, res := range out { + ids = append(ids, fmt.Sprint(res["id"])) + } + if len(ids) == 0 || ids[0] != "networking."+NeedID("key") { + t.Fatalf("a computed module lost the secret the mesh made for it, or applies it late: %v", ids) + } +} + +type computedOnce struct{} + +func (computedOnce) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "wg", "type": "file", "path": "/etc/wireguard/wg0.conf", + "content": "x", "mode": "0600"}}, true, nil +} + +// A consumer bound to something answered on its own machine still has to be told where it is. +// +// The location is a fact nobody needs — "it is here" — and everything beside it is not: the +// binding carries what the provider said a consumer must know, which is the port. A build machine +// sharing a node with the registry it pushes to sat in a loop saying it could not read its own +// binding, because none was written. +func TestSomethingAnsweredOnThisMachineIsStillBound(t *testing.T) { + r := Resolution{ + Node: "anchor", At: "anchor.internal", + Modules: []Manifest{ + {Module: "registry", Provides: []Offer{{Name: "artifact-store", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}}, + {Module: "builder", Requires: []string{"artifact-store"}, + Binds: map[string]string{"artifact-store": "/var/lib/builder/store.json"}}, + }, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatalf("declaration: %v", err) + } + var content string + for _, res := range out { + if res["path"] == "/var/lib/builder/store.json" { + content, _ = res["content"].(string) + } + } + if content == "" { + t.Fatal("nothing was written, so the consumer cannot find a provider on its own machine") + } + if !strings.Contains(content, "5000") { + t.Fatalf("the binding does not carry the port, which is the part nobody can guess:\n%s", content) + } + if !strings.Contains(content, "anchor.internal") { + t.Fatalf("the binding does not say where to reach it:\n%s", content) + } +} + +// And a machine not on the private network still reaches itself. +func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T) { + r := Resolution{ + Node: "alone", + Modules: []Manifest{ + {Module: "registry", Provides: []Offer{{Name: "artifact-store", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}}, + {Module: "builder", Requires: []string{"artifact-store"}, + Binds: map[string]string{"artifact-store": "/var/lib/builder/store.json"}}, + }, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatalf("declaration: %v", err) + } + for _, res := range out { + if res["path"] != "/var/lib/builder/store.json" { + continue + } + content, _ := res["content"].(string) + if !strings.Contains(content, "127.0.0.1") { + t.Fatalf("bound by a name nothing on this machine resolves:\n%s", content) + } + return + } + t.Fatal("nothing was written") +} + +// mustFilter is the rule set, refusing to continue if it could not be computed. +func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule { + t.Helper() + rules, err := r.Filtering(computed, nil, nil) + if err != nil { + t.Fatalf("no rule set could be computed: %v", err) + } + return rules +} + +// opensOnHub is a generator that says a machine opens a port because of where it sits. +type opensOnHub struct{ hub string } + +func (opensOnHub) Resources(string) ([]map[string]any, bool, error) { return nil, true, nil } + +func (o opensOnHub) Listens(node string) ([]Listening, error) { + if node != o.hub { + return nil, nil + } + return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere, + Why: "the private network"}}, nil +} + +// cannotSay is a generator that does not know what a machine opens. +type cannotSay struct{} + +func (cannotSay) Resources(string) ([]map[string]any, bool, error) { return nil, true, nil } +func (cannotSay) Listens(string) ([]Listening, error) { + return nil, errors.New("this machine's endpoint has no port in it") +} + +// A computed module contributes listens the way it contributes resources. +// +// A static field is one answer for every machine that runs the module, and a hub's own port is +// not one of those: the machine that most needs filtering — the one facing the public internet — +// is exactly the one a static answer gets wrong. +func TestAComputedModuleOpensThePortItsMachineNeeds(t *testing.T) { + network := Manifest{Module: "networking", Computed: "mesh-network"} + gens := map[string]Generator{"mesh-network": opensOnHub{hub: "anchor"}} + + onHub := mustFilter(t, Resolution{Node: "anchor", Modules: []Manifest{network}}, gens) + if len(onHub) != 1 || onHub[0].Port != 51820 { + t.Fatalf("the hub's own way onto the private network was not opened: %+v", onHub) + } + if onHub[0].Because[0] != "networking" { + t.Fatalf("the rule does not name what caused it: %+v", onHub[0]) + } + + // And the machine that dials out opens nothing, from the same module. + elsewhere := mustFilter(t, Resolution{Node: "laptop", Modules: []Manifest{network}}, gens) + if len(elsewhere) != 0 { + t.Fatalf("a machine nothing dials opened a port because another machine needed one: %+v", + elsewhere) + } +} + +// What a module says and what was computed for it are both kept. +func TestAComputedModulesOwnListensAreNotLost(t *testing.T) { + network := Manifest{Module: "networking", Computed: "mesh-network", + Listens: []Listening{{Port: 9, From: FromMesh, Why: "something the module always wants"}}} + rules := mustFilter(t, Resolution{Node: "anchor", Modules: []Manifest{network}}, + map[string]Generator{"mesh-network": opensOnHub{hub: "anchor"}}) + if len(rules) != 2 { + t.Fatalf("one of the two sources was dropped: %+v", rules) + } +} + +// A generator that cannot say is refused, not read as silence. +// +// Closing a port on the evidence of a failure to look is how a machine is severed by a fault +// somewhere else entirely — and the machine it would sever is the hub. +func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) { + _, err := Resolution{Node: "anchor", + Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}}, + }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil, nil) + if err == nil { + t.Fatal("a machine whose open ports could not be computed was given a rule set anyway") + } + if !strings.Contains(err.Error(), "anchor") { + t.Fatalf("the refusal does not name the machine: %v", err) + } +} + +// A generator with nothing to say about ports is ordinary and must not be required to say so. +func TestAGeneratorThatOpensNothingNeedsNoMethod(t *testing.T) { + rules := mustFilter(t, Resolution{Node: "anchor", + Modules: []Manifest{{Module: "names", Computed: "mesh-names"}}}, + map[string]Generator{"mesh-names": computedOnce{}}) + if len(rules) != 0 { + t.Fatalf("a generator that says nothing about ports opened one: %+v", rules) + } +} + +// A module may reflect a file another module put on the machine. +// +// The case this exists for: a resolver restarting when the mesh rewrites the names. Those are +// computed by the mesh and belong to its module, not to the daemon's — so without this the +// daemon would serve the names it started with for ever, while every check reported success. +func TestAServiceCanReflectAnotherModulesFile(t *testing.T) { + out, err := Resolution{Node: "anchor", Modules: []Manifest{{ + Module: "dnsmasq", + Resources: []map[string]any{ + {"id": "config", "type": "file", "path": "/etc/dnsmasq.conf", + "content": "x", "mode": "0644"}, + {"id": "run", "type": "service", "unit": "dnsmasq.service", "state": "running", + "restart-on": []any{"config", "mesh-resolver.nodes"}}, + }, + }}}.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, r := range out { + if r["type"] != "service" { + continue + } + reflects := r["restart-on"].([]any) + var own, other bool + for _, id := range reflects { + switch id.(string) { + case "dnsmasq.config": + own = true + case "mesh-resolver.nodes": + other = true + } + } + if !own { + t.Fatalf("its own file was not prefixed with its module: %v", reflects) + } + if !other { + t.Fatalf("a file it named in full was prefixed again and now points at nothing: %v", + reflects) + } + return + } + t.Fatal("no service in the declaration") +} + +// What answers a requirement is applied before what asked for it. +// +// The host does not sort, so the order written here is the order a machine applies. Selection +// walks outward from what was assigned, which puts a consumer first — and a service that reads a +// file another module writes then starts before the file exists, fails, and is fixed by the next +// reconcile. That is the worst shape a fault can take: what gets remembered is that it works. +func TestWhatAnsweredARequirementIsAppliedFirst(t *testing.T) { + got, err := Resolve(map[string]Manifest{ + "dnsmasq": {Module: "dnsmasq", Requires: []string{"resolver-data"}}, + "data": {Module: "data", Provides: []Offer{{Name: "resolver-data"}}}, + }, []string{"dnsmasq"}, Node{Name: "anchor"}, World{}) + if err != nil { + t.Fatal(err) + } + if got.Modules[0].Module != "data" { + t.Fatalf("the consumer is applied before what it requires: %v", named(got)) + } +} + +// Through a chain, not only one step: a machine applies what the thing it needs needs, first. +func TestAChainOfRequirementsIsAppliedFromTheBottom(t *testing.T) { + got, err := Resolve(map[string]Manifest{ + "top": {Module: "top", Requires: []string{"middle-thing"}}, + "middle": {Module: "middle", Provides: []Offer{{Name: "middle-thing"}}, Requires: []string{"bottom-thing"}}, + "bottom": {Module: "bottom", Provides: []Offer{{Name: "bottom-thing"}}}, + }, []string{"top"}, Node{Name: "anchor"}, World{}) + if err != nil { + t.Fatal(err) + } + order := named(got) + if order[0] != "bottom" || order[1] != "middle" || order[2] != "top" { + t.Fatalf("a chain was not applied from the bottom: %v", order) + } +} + +// Modules with no relation keep the order selection gave them — assigned first, then what they +// pulled in. A sort that reshuffled unrelated modules would make every declaration's diff +// unreadable for no gain. +func TestUnrelatedModulesKeepTheirOrder(t *testing.T) { + got, err := Resolve(map[string]Manifest{ + "one": {Module: "one"}, + "two": {Module: "two"}, + "three": {Module: "three"}, + }, []string{"one", "two", "three"}, Node{Name: "anchor"}, World{}) + if err != nil { + t.Fatal(err) + } + order := named(got) + if order[0] != "one" || order[1] != "two" || order[2] != "three" { + t.Fatalf("unrelated modules were reshuffled: %v", order) + } +} + +// Two modules requiring each other are both applied rather than refused: a cycle here is not a +// machine that cannot work, and refusing would make a cooperating pair impossible to assign. +func TestTwoModulesRequiringEachOtherAreStillApplied(t *testing.T) { + got, err := Resolve(map[string]Manifest{ + "a": {Module: "a", Provides: []Offer{{Name: "from-a"}}, Requires: []string{"from-b"}}, + "b": {Module: "b", Provides: []Offer{{Name: "from-b"}}, Requires: []string{"from-a"}}, + }, []string{"a"}, Node{Name: "anchor"}, World{}) + if err != nil { + t.Fatal(err) + } + if len(got.Modules) != 2 { + t.Fatalf("a cooperating pair was not applied: %v", named(got)) + } +} + +func named(r Resolution) []string { + var out []string + for _, m := range r.Modules { + out = append(out, m.Module) + } + return out +} + +// A port's source is a per-node setting, not a manifest constant: the same module is internal on +// one machine and public on another (novox/hq ADR 0046). postgres listens from the mesh by default; +// a setting on one node exposes it to anywhere, and the rule set follows. +func TestExposureSettingOverridesAListensSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Version: "1", + Listens: []Listening{{Port: 5432, From: FromMesh, Why: "the database"}}} + + base, err := Resolution{Node: "ace", Modules: []Manifest{postgres}}.Filtering(nil, nil, nil) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(base) != 1 || base[0].From != FromMesh { + t.Fatalf("without a setting, the default source is the mesh: %+v", base) + } + + expose := map[string]map[int]string{"postgres": {5432: FromEverywhere}} + exposed, err := Resolution{Node: "novox", Modules: []Manifest{postgres}}.Filtering(nil, nil, expose) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(exposed) != 1 || exposed[0].From != FromEverywhere { + t.Fatalf("the setting did not open the port to anywhere: %+v", exposed) + } +} + +// Exposure refuses a setting that names a port the module does not listen on, or a source that is +// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0043/0046). +func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}} + layer := func(port, source string) []Layer { + return []Layer{{From: "node", Placement: map[string]any{ExposeSetting: map[string]any{port: source}}}} + } + + if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil { + t.Fatalf("a valid exposure was refused: %v", err) + } + if _, err := Exposure(postgres, layer("6379", FromEverywhere)); err == nil { + t.Error("a port the module does not listen on was accepted") + } + if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil { + t.Error("a source that is not mesh/anywhere/machine was accepted") + } +} + +// ssh survives whatever the mesh does or does not know about this machine. +// +// **The one rule here that nothing derives.** Every other line comes from what is assigned, which +// is the point of the mechanism — and a mesh part-way through adopting a machine has been assigned +// almost nothing, so what it computes is a chain that drops the port used to fix it. The session +// loading the rules lives on conntrack until it drops, and then the machine is reached from a +// rescue console (novox/hq issue 047). +func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0") + if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 22 accept") { + t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft) + } + // And not to the world, on a machine that does not face it. + if strings.Contains(nft, "\t\ttcp dport 22 accept") { + t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft) + } +} + +// And from outside as well, on a machine that faces outward — because that is the way in when the +// private network is the thing that broke. +func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0") + if !strings.Contains(nft, "\t\ttcp dport 22 accept") { + t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft) + } +} + +// A machine the mesh knows no addresses for still answers ssh. +// +// **This is the machine it matters most for.** Before the private network exists there is nothing +// to narrow the rule to, so narrowing it shuts the port entirely — on the first machine anybody +// adopts, reached over the network, closed by the act of adopting it. +func TestSSHIsNeverLeftWithoutARule(t *testing.T) { + nft := AsNftables(nil, nil, false, nil, nil, "mesh0") + if !strings.Contains(nft, "tcp dport 22 accept") { + t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft) + } +} + +// chainBody is one chain's own lines, so an assertion cannot be satisfied by an identical line in +// another chain. +// +// **Written because that happened.** The rule letting this machine's own callers through appears in the +// input chain and, in the same words, in the forward chain. A test asserting on the whole rendered file +// passed with the input chain's copy deleted — it was reading the forward chain's. ADR 0137's own tests +// say to assert per chain body for exactly this reason, and this file was not doing it. +func chainBody(t *testing.T, nft, chain string) string { + t.Helper() + open := "\tchain " + chain + " {" + i := strings.Index(nft, open) + if i < 0 { + t.Fatalf("no chain %q in:\n%s", chain, nft) + } + rest := nft[i+len(open):] + j := strings.Index(rest, "\n\t}") + if j < 0 { + t.Fatalf("chain %q does not close in:\n%s", chain, nft) + } + return rest[:j] +} + +// **Anything on this machine may call anything on this machine.** +// +// Local is not a boundary this mesh draws, and whether a caller sits in a container was never meant to +// change the answer. It did, because the chain asked about addresses: a caller on the machine carries +// the machine's address and a caller in one of its containers carries a bridge address, so a rule +// naming the machines' own addresses silently refused every container on them. +// +// Measured: a module reaching its database on its own machine's name timed out for eleven hours while +// the machine itself could reach it (novox/hq 04-ISSUES/145). +func TestAnythingOnThisMachineMayCallAnythingOnIt(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + {Module: "private", Listens: []Listening{{Port: 9999, From: FromMachine}}}, + }}, nil), []string{"10.10.0.1", "10.10.0.2"}, false, nil, []string{"eth0"}, "mesh0") + + // In the INPUT chain, which is where a call to a service on this machine arrives. The forward + // chain carries the same line in the same words, so asserting on the whole file proves nothing. + input := chainBody(t, nft, "input") + if !strings.Contains(input, `iifname != { "eth0", "mesh0" } accept`) { + t.Fatalf("a caller on this machine cannot reach a service on it:\n%s", input) + } + // One rule, for every service here — not a line per port that only covers the ports somebody + // remembered to think about. + if strings.Contains(input, `iifname != { "eth0", "mesh0" } tcp dport 5432`) { + t.Fatalf("the local allowance is still written per port:\n%s", input) + } + // And the private network still reaches what is exposed to it, which is a different question. + if !strings.Contains(input, "ip saddr { 10.10.0.1, 10.10.0.2 } tcp dport 5432 accept") { + t.Fatalf("the private network no longer reaches a service exposed to it:\n%s", input) + } +} + +// The three reaches, as three lines. This is the whole of what the filter says about who may call what. +func TestTheThreeReachesAreThreeLines(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "internal-only", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + {Module: "public", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0") + + input := chainBody(t, nft, "input") + for what, want := range map[string]string{ + "on this machine": `iifname != { "eth0", "mesh0" } accept`, + "over the private network": "ip saddr { 10.10.0.1 } tcp dport 5432 accept", + "from anywhere": "tcp dport 443 accept", + } { + if !strings.Contains(input, want) { + t.Fatalf("a caller %s cannot reach what is exposed to it (%q):\n%s", what, want, input) + } + } +} + +// A port open to everything needs no such line — it is already open to a guest. +func TestAPublicPortNeedsNoGuestLine(t *testing.T) { + nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0") + if strings.Count(nft, `iifname != { "eth0", "mesh0" } tcp dport 443`) != 0 { + t.Fatalf("a public port was given a guest line it does not need:\n%s", nft) + } +} + +// **The hub relays the mesh** (novox/hq ADR 0007, issue 196). Two machines that are not co-located +// reach each other through the hub, so the hub forwards a packet that arrives on the tunnel and +// leaves on it. The forward chain judged that packet by the hub's own published ports, and two +// machines behind the hub reached each other only on the ports the hub happened to publish. +// +// Measured: from one home machine to another through the hub, 17 of 55 ports answered, and they +// were exactly the hub's own; the SYN for the rest never left the hub. +func TestTheMeshPassingThroughIsRelayedNotJudgedAsThisMachines(t *testing.T) { + nft := AsNftables(nil, []string{"10.42.0.1", "10.42.0.2"}, false, nil, []string{"eth0"}, "mesh0") + relay := `iifname "mesh0" oifname "mesh0" accept` + if !strings.Contains(chainBody(t, nft, "forward"), relay) { + t.Errorf("the forward chain does not relay the mesh through this machine:\n%s", chainBody(t, nft, "forward")) + } + // Relaying is not receiving: nothing in the input chain opens because of it. + if strings.Contains(chainBody(t, nft, "input"), "oifname") { + t.Errorf("the input chain names an outgoing interface, which no packet for this machine has:\n%s", + chainBody(t, nft, "input")) + } + // And off the tunnel into this machine's own containers is still judged: the tunnel is not + // accepted wholesale, only in and out on it. + if strings.Contains(chainBody(t, nft, "forward"), `iifname "mesh0" accept`) { + t.Errorf("the forward chain accepts everything off the tunnel:\n%s", chainBody(t, nft, "forward")) + } + + // A machine with no tunnel relays nothing, and names no interface it does not have. + alone := AsNftables(nil, nil, false, nil, []string{"eth0"}, "") + if strings.Contains(alone, "oifname") { + t.Errorf("a machine with no tunnel was given a relay rule:\n%s", alone) + } +} diff --git a/go/catalogue/go_bundle_test.go b/go/catalogue/go_bundle_test.go new file mode 100644 index 0000000..eb6cd2b --- /dev/null +++ b/go/catalogue/go_bundle_test.go @@ -0,0 +1,37 @@ +package catalogue + +import "testing" + +// A bundle is the module's own directory compiled whole, and naming a source would be describing its +// own build. That holds for an interpreted language and cannot hold for a compiled one: a repository +// written in Go carries several commands — the host and its bootstrap live in one — and "the module's +// own directory" is then not a package at all (novox/hq 04-ISSUES/142). + +func TestAGoBundleMayNameItsCommand(t *testing.T) { + b := &Build{Artifacts: []Artifact{{ + Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch", + From: "cmd/mesh-host", + }}} + if p := b.problems("mesh-host"); len(p) != 0 { + t.Fatalf("a go bundle naming its command was refused: %v", p) + } +} + +func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) { + b := &Build{Artifacts: []Artifact{{ + Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src", + }}} + p := b.problems("something") + if len(p) == 0 { + t.Fatal("an interpreted bundle naming what it is built from was accepted") + } +} + +func TestACompiledBundleStillMustSayItsSystem(t *testing.T) { + b := &Build{Artifacts: []Artifact{{ + Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host", + }}} + if len(b.problems("mesh-host")) == 0 { + t.Fatal("a compiled bundle with no system was accepted") + } +} diff --git a/go/catalogue/grant_secret_owner_test.go b/go/catalogue/grant_secret_owner_test.go new file mode 100644 index 0000000..5fdf7fe --- /dev/null +++ b/go/catalogue/grant_secret_owner_test.go @@ -0,0 +1,103 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A grant secret is read by whatever provisions, and that stopped being root (novox/hq issue 225). +// +// The mesh seals one credential per consumer beside the provider's contributions file. The +// provider's harness reads both: the file to learn who asked, the secret to set their password. +// While a module's own code ran in a container as root, a root-owned 0600 file was readable by +// the thing that needed it. ADR 0198 moved that code under the node's runtime, which runs as the +// operator's account — and the secret stayed root's. +// +// **The cost was silence.** The harness says `secret not readable yet`, which is true and +// ordinary on the first pass, so four thousand refusals in three hours read as patience. No user +// was ever created, and two consumers crash-looped against a database that had never heard of +// them. +// +// The same reasoning is already written for a module's *own* secrets, three hundred lines above: +// "a root-owned 0600 file is one that process cannot read". This is that rule reaching the other +// kind of secret the mesh writes for a module. + +// aProviderWithABundle is a provider whose code is a bundle the node's runtime runs — the shape +// every TypeScript provisioner has since ADR 0198. +func aProviderWithABundle() Manifest { + return Manifest{ + Module: "mongodb", Version: "1", + Provides: FromAnywhere("mongodb-database"), + Receives: map[string]string{"mongodb-database": "/var/lib/mongodb/grants/mesh.json"}, + Grants: map[string]string{"mongodb-database": "/var/lib/mongodb/grants"}, + Bundles: []Bundle{{Name: "code", Language: "typescript"}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "mongodb-server", + "image": "mongo@sha256:" + strings.Repeat("a", 64), + }}, + } +} + +func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) { + r, err := Resolve(shelf(aProviderWithABundle()), []string{"mongodb"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "operator" + out, err := r.Declaration(Rendering{Grants: []Grant{{ + Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}) + if err != nil { + t.Fatal(err) + } + + var secret map[string]any + for _, res := range out { + if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") { + secret = res + } + } + if secret == nil { + t.Fatalf("no grant secret was composed at all: %v", out) + } + if got := secret["owner"]; got != "operator" { + t.Fatalf("the grant secret at %v belongs to %v; the provisioner runs as %q and a "+ + "root-owned 0600 file is one it cannot read — which is silent, because the harness "+ + "calls it \"not readable yet\"", fmtPath(secret), got, "operator") + } +} + +// And a provider whose code still runs in a container keeps the owner it declares, so this +// changes nothing for the modules the runtime has not taken. +func TestAContainerProvidersGrantSecretKeepsItsDeclaredOwner(t *testing.T) { + m := aProviderWithABundle() + m.Bundles = nil + m.SecretsOwner = "65534:65534" + r, err := Resolve(shelf(m), []string{"mongodb"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "operator" + out, err := r.Declaration(Rendering{Grants: []Grant{{ + Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") { + if got := res["owner"]; got != "65534:65534" { + t.Fatalf("a container provider's grant secret belongs to %v, not what it declares", got) + } + return + } + } + t.Fatal("no grant secret was composed") +} + +func fmtPath(r map[string]any) string { + p, _ := r["path"].(string) + return p +} diff --git a/go/catalogue/graphical_session_test.go b/go/catalogue/graphical_session_test.go new file mode 100644 index 0000000..f412e24 --- /dev/null +++ b/go/catalogue/graphical_session_test.go @@ -0,0 +1,213 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats. + +// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with. +func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) { + want := map[string][]string{ + LoginManagerSeat: {"sessions"}, + DisplayServerSeat: {"displays", "layout"}, + DisplaySessionSeat: {"reload", "workspaces", "windows"}, + TerminalEmulatorSeat: {"open"}, + LauncherSeat: {"menu", "secret"}, + NotifierSeat: {"send", "history"}, + LockScreenSeat: {"lock"}, + ClipboardSeat: {"history", "copy"}, + BarSeat: nil, + CompositorSeat: nil, + SecretServiceSeat: nil, + } + for name, verbs := range want { + s, ok := SeatNamed(name) + if !ok { + t.Errorf("%s is not in the mesh's set", name) + continue + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" { + t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Description == "" || v.Input["type"] != "object" { + t.Errorf("%s.%s has no description or no object schema", name, v.Name) + } + } + if !reflect.DeepEqual(got, verbs) { + t.Errorf("%s serves %v, want %v", name, got, verbs) + } + } + // The launcher's menu takes a list, and the layout verb says its actions. + menu, _ := SeatNamed(LauncherSeat) + choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any) + if choices["type"] != "array" { + t.Errorf("menu's choices are %v, not a list", choices["type"]) + } + display, _ := SeatNamed(DisplayServerSeat) + action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any) + if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) { + t.Errorf("layout's actions are %v", action["enum"]) + } + // And they survive the store's JSON, which is where the live set comes from. + if _, err := json.Marshal(graphicalSessionSeats()); err != nil { + t.Fatal(err) + } +} + +// §2: a module may claim one of them, and may not declare it as its own. +func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) { + raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") { + t.Fatalf("a module declared node-display-server as its own: %v", err) + } +} + +func displayServer(name, display string, claims ...string) Manifest { + m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}} + for _, c := range claims { + m.Claims = append(m.Claims, Claim{Name: c}) + } + return m +} + +func windowManager() Manifest { + return Manifest{Module: "i3", Requires: []string{"x11-display"}} +} + +// §3: a display is resolved on the requiring module's own node. +func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) { + cat := shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)) + got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{}) + if err != nil { + t.Fatalf("i3 beside xorg did not resolve: %v", err) + } + if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) { + t.Errorf("resolved %v", names(got)) + } +} + +// §3: never answered by installing a provider, and never by another machine's. +func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) { + cat := shelf(windowManager(), + displayServer("xorg", "x11-display", DisplayServerSeat), + displayServer("xwayland", "x11-display")) + // Another machine runs xorg and says so to the world; it does not count. + world := World{Offered: map[string][]Provider{ + "x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}} + _, err := Resolve(cat, []string{"i3"}, workstation(), world) + if err == nil { + t.Fatal("i3 resolved on a machine with no display of its own") + } + for _, want := range []string{ + `"x11-display" is wanted by i3`, "usable only on the machine that provides it", + "assign one to workstation", "xorg (holds node-display-server)", "xwayland", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } + // With a single provider in the catalogue too: one candidate is still not a choice to make + // for somebody, unlike a node-scoped provision without the machine's reach. + _, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)), + []string{"i3"}, workstation(), World{}) + if err == nil { + t.Fatal("xorg was pulled in for i3") + } + // Not in the first pass, whose refusals take the machine off the network. + if _, err := Resolve(cat, []string{"i3"}, workstation(), World{Unchecked: true}); err != nil { + t.Errorf("the first pass refused: %v", err) + } +} + +func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) { + for _, c := range []struct{ provides, want string }{ + {`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`}, + {`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`}, + } { + _, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.provides, c.want, err) + } + } + m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`)) + if err != nil { + t.Fatal(err) + } + if !m.Provides[0].MachineReach() { + t.Fatal("the reach was not read") + } + back, _ := json.Marshal(m.Provides[0]) + if string(back) != `{"name":"x11-display","reach":"machine"}` { + t.Errorf("written back as %s", back) + } +} + +func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) { + cat := shelf(windowManager(), displayServer("xorg", "x11-display"), + Manifest{Module: "fake-x", Provides: Offers("x11-display")}) + _, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{}) + if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) { + t.Fatalf("a provision with and without the machine's reach gave %v", err) + } +} + +// §4: xinitrc and xresources slots, placed by the display server's holder alone. +func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) { + xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}}, + Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}}, + Resources: []map[string]any{ + {"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc", + "content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"}, + {"id": "xresources", "type": "file", "path": "/home/op/.Xresources", + "content": "${shell:xresources:normal}"}, + }} + i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}} + theme := Manifest{Module: "theme", Shell: []ShellCode{ + {For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"}, + {For: "zsh", Slot: "normal", Code: "not for the session"}, + }} + r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + by := map[string]any{} + for _, res := range out { + by[res["id"].(string)] = res["content"] + } + if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" { + t.Errorf("the .xinitrc is %q", got) + } + if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" { + t.Errorf("the .Xresources is %q", got) + } + + // The contributions parse; the placeholders parse only in the holder. + if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` + + `{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil { + t.Fatalf("a session contribution was refused: %v", err) + } + for _, c := range []struct{ claims, content, want string }{ + {``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"}, + {`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"}, + {`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"}, + {`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"}, + } { + raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err) + } + } + if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` + + `"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil { + t.Errorf("the display server's holder could not place the session's slots: %v", err) + } +} diff --git a/go/catalogue/handed_bound_test.go b/go/catalogue/handed_bound_test.go new file mode 100644 index 0000000..526f4d2 --- /dev/null +++ b/go/catalogue/handed_bound_test.go @@ -0,0 +1,159 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// gitea's server.env, as the catalogue keeps it: its database as the provider told it and the consumer's own login. +const giteaServerEnv = "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n" + + "GITEA__database__DB_TYPE=postgres\n" + + "GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\n" + + "GITEA__database__NAME=${bound:postgres-database:as}\n" + + "GITEA__database__USER=${bound:postgres-database:as}\n" + + "GITEA__database__PASSWD=${secret:postgres-database}\n" + +// databaseConsumer is a handed module, with the template gitea's server.env is, requiring a database from a provider +// that has moved or not (issue 788); slug is the module's identity source when it declares one. +func databaseConsumer(t *testing.T, slug string, moved bool) Resolution { + t.Helper() + raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, + `"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1) + if slug != "" { + raw = strings.Replace(raw, `"version":"1",`, `"version":"1","slug":"`+slug+`",`, 1) + } + return withDatabase(t, raw, giteaServerEnv, moved) +} + +// withDatabase resolves the module in raw, whose template is text, against a provider of postgres-database that has +// moved or not. +func withDatabase(t *testing.T, raw, text string, moved bool) Resolution { + t.Helper() + r, err := Resolve(shelf(held(t, raw, builtBot(text)), + Manifest{Module: "postgres", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeNode}}}), + []string{"bot"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + // Answered on this machine, it states no need; one from the provider's machine is stated, as the plan does. + r.Needs = []Needed{{Name: "postgres-database", For: "bot", Module: "postgres", From: "novox", At: "10.7.0.1", + CredentialForm: CredentialFormPair, IssuedToHolder: moved, ProviderMoved: moved}} + return r +} + +// The forge's server.env composes for a handed module when what it requires has moved, its plan entry carrying the +// provider's address, machine and the consumer's identity as today's binding file gives them; and is refused with the +// text it has always had when the provider has not. +func TestAHandedModuleMayNameWhatItRequiresWhereItsProviderHasMoved(t *testing.T) { + c, got := composeWith(t, databaseConsumer(t, "", true), handingTo("store:5000")) + if strings.Contains(got, "sealed") { + t.Fatalf("a handed module's declaration carries a sealed value: %s", got) + } + want := []PlanProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox", + ProviderModule: "postgres", CredentialForm: CredentialFormPair, At: "10.7.0.1", From: "novox", + As: ConsumerIdentity("workstation", "bot")}} + if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) { + t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want) + } + for _, unmoved := range []Resolution{databaseConsumer(t, "", false)} { + _, err := unmoved.Compose(handingTo("store:5000")) + if err == nil || !strings.Contains(err.Error(), "which is no secret of its own; a credential it requires moves "+ + "with its provider (design 57 B1–B3)") { + t.Fatalf("a provider not moved, the credential: %v", err) + } + bound := strings.Replace(giteaServerEnv, "${secret:postgres-database}", "x", 1) + r := withDatabase(t, strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, + `"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1), + bound, false) + _, err = r.Compose(handingTo("store:5000")) + if err == nil || !strings.Contains(err.Error(), "the provisioner's client fills only config, secrets, "+ + "collections and placement until that domain moves (design 57)") { + t.Fatalf("a provider not moved, the binding: %v", err) + } + } +} + +// A module with a slug is told who it is as its binding file says, the slug first (issue 788). +func TestAHandedModulesPlanEntryCarriesItsIdentityAsTheBindingFileGivesIt(t *testing.T) { + c, _ := composeWith(t, databaseConsumer(t, "agent", true), handingTo("store:5000")) + if got := c.Plans["bot"].Provisions[0].As; got != ConsumerIdentity("workstation", "agent") { + t.Fatalf("the plan entry says %q", got) + } + // Nothing known of where the provider is: the entry says none. + c, _ = composeWith(t, forgeReader(t, true, true), handingTo("store:5000")) + if p := c.Plans["bot"].Provisions[0]; p.At != "" || p.From != "novox" { + t.Fatalf("the entry says at %q from %q", p.At, p.From) + } +} + +// A handed module's credentials the vault keeps as one login per local name (issue 793): gitea's internal token and +// admin password, which the `secret` provision hands as `${secret:}`. Each is a plan entry naming its local, +// its file declared placed and without a value; the template naming the local is allowed. +func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *testing.T) { + raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, + `"requires":["secret"],"secrets":{"secret":{"internal-token":"${dir:state}/internal-token.secret",`+ + `"admin":"${dir:state}/admin.secret"}},"own-secrets":{"token":"${dir:state}/token"}`, 1) + r, err := Resolve(shelf(held(t, raw, builtBot("T=${secret:internal-token}\n")), + Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret", Scope: ScopeNode}}}), + []string{"bot"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + r.Needs = []Needed{ + {Name: "secret", Local: "internal-token", For: "bot", Module: "vault", From: "novox", + CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true}, + {Name: "secret", Local: "admin", For: "bot", Module: "vault", From: "novox", + CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true}, + } + c, got := composeWith(t, r, handingTo("store:5000")) + if strings.Contains(got, "sealed") { + t.Fatalf("the declaration carries a sealed value: %s", got) + } + var names []string + for _, p := range c.Plans["bot"].Provisions { + if p.Provision != "secret" || p.Provider != "novox" || p.Path == "" || p.Name != p.Local { + t.Errorf("the plan entry is %+v", p) + } + names = append(names, p.Name) + } + if !reflect.DeepEqual(names, []string{"admin", "internal-token"}) { + t.Fatalf("the plan names %v, want admin and internal-token", names) + } +} + +// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a +// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes +// handed files the client fills it and the module composes; where it does not the controller must fill it from what +// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with +// (ADR 0349 §17: the old way, byte for byte). +func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) { + text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n" + moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, + `"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1) + unmoved := strings.Replace(moved, `"moved":true,`, ``, 1) + if unmoved == moved { + t.Fatal("the twin is still marked moved") + } + + c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000")) + if strings.Contains(got, "known_host") || len(c.Filled) == 0 { + t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got) + } + + for name, with := range map[string]Rendering{ + "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + } { + _, err := withDatabase(t, moved, text, true).Compose(sealedForBot(with)) + if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) { + t.Fatalf("%s: the key is not refused: %v", name, err) + } + _, before := withDatabase(t, unmoved, text, false).Compose(sealedForBot(with)) + if before == nil || before.Error() != err.Error() { + t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before) + } + } +} diff --git a/go/catalogue/handed_consumers_test.go b/go/catalogue/handed_consumers_test.go new file mode 100644 index 0000000..a93f6ea --- /dev/null +++ b/go/catalogue/handed_consumers_test.go @@ -0,0 +1,134 @@ +package catalogue + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A provider's consumers' credentials reach it in a file its machine's provisioner client fills (novox/hq ADR 0373, +// issue 679): `${consumers:}`, which only a provider's template may hold, for a provision it provides, in a +// file of mode 0600 owned at runtime; filled by the client alone, so a module not handed to it on a machine is +// refused there rather than written the placeholder's text, or nothing. + +const ( + handedProvider = `{"module":"db","version":"1","moved":true, + "provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"state","type":"directory","mode":"0700","place":"."}, + {"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}` + consumersText = "${consumers:postgres-database}" +) + +func builtProvider(text string) []Built { + return []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + "db/files/blobs/sha256:" + strings.Repeat("f", 64), + Digest: "sha256:" + strings.Repeat("f", 64), Texts: map[string]string{"consumers.json": text}}} +} + +func TestAHandedProvidersConsumersFileIsHandedToTheClient(t *testing.T) { + r := notifierOn(t, held(t, handedProvider, builtProvider(consumersText))) + c, got := composeWith(t, r, handingTo("store:5000")) + if strings.Contains(got, "${consumers:") { + t.Fatalf("the declaration carries the placeholder: %s", got) + } + plan := c.Plans["db"] + if len(plan.Files) != 1 || plan.Files[0].Template != "consumers.json" || plan.Files[0].OwnerAtRuntime != RuntimeOwned { + t.Fatalf("the plan fills %+v, want the consumers' file owned at runtime", plan.Files) + } +} + +func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) { + for why, c := range map[string]struct{ raw, text, want string }{ + "a provision the module does not provide": {handedProvider, "${consumers:cache}", "does not provide"}, + "a module providing nothing": {strings.Replace(handedProvider, `"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],`, + "", 1), consumersText, "does not provide"}, + "a file others may read": {strings.Replace(handedProvider, `"mode":"0600","template"`, + `"mode":"0640","template"`, 1), consumersText, "0600"}, + "a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`, + `"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"}, + } { + m, err := ParseManifest([]byte(c.raw)) + if err != nil { + t.Fatalf("%s: %v", why, err) + } + resolved, err := m.Resolve(builtProvider(c.text)) + if err == nil { + r := notifierOn(t, resolved) + _, err = r.Compose(handingTo("store:5000")) + } + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want a refusal saying %q", why, err, c.want) + } + } +} + +// Where the provider is not handed, nothing fills the placeholder, and it is never written as its text or as an empty +// list the provider would read as no consumers: the file is left out, said once, and the module composes exactly as +// it would without the file, so a provider below the handing contract keeps working from its grant files (issue 780). +func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) { + withFile := notifierOn(t, held(t, handedProvider, builtProvider(consumersText))) + without := strings.Replace(handedProvider, `, + {"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}`, "", 1) + if without == handedProvider { + t.Fatal("the manifest without the file was not made") + } + bare := notifierOn(t, held(t, strings.Replace(strings.Replace(without, `"moved":true,`, "", 1), + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1), nil)) + for name, with := range map[string]Rendering{ + "a node-engine that fills no file": {}, + "a node-engine that fills, no client": {Hands: true, ArtifactStore: "store:5000"}, + "a client, a node-engine that does not": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + } { + c, got := composeWith(t, withFile, with) + _, want := composeWith(t, bare, with) + if got != want { + t.Errorf("%s: composes\n%s\nwithout its file, it composed\n%s", name, got, want) + } + if strings.Contains(got, "consumers") { + t.Errorf("%s: the declaration names the consumers' file: %s", name, got) + } + if len(c.HandedOnly) != 1 || !strings.Contains(c.HandedOnly[0], "left out, handed only") { + t.Errorf("%s: said %q, want the one line saying it is left out", name, c.HandedOnly) + } + if len(c.Unplaced) != 0 || len(c.Filled) != 0 || len(c.Plans) != 0 { + t.Errorf("%s: unplaced %v, filled %v, plans %v", name, c.Unplaced, c.Filled, c.Plans) + } + } + // Where it is handed, the file is there and nothing is said. + c, _ := composeWith(t, withFile, handingTo("store:5000")) + if len(c.HandedOnly) != 0 || len(c.Filled) != 1 { + t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled) + } +} + +// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same +// rules, a provider naming a provision it does not provide is refused there too, and one that does composes. +func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) { + read := func(text string) (Manifest, error) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + m, err := ParseManifest([]byte(handedProvider)) + if err != nil { + t.Fatal(err) + } + return WithTemplatesFrom(m, dir) + } + filled, err := read(consumersText) + if err != nil { + t.Fatalf("a provider read from its tree is refused: %v", err) + } + if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil { + t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err) + } + if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err) + } +} diff --git a/go/catalogue/handed_merge_test.go b/go/catalogue/handed_merge_test.go new file mode 100644 index 0000000..32faf96 --- /dev/null +++ b/go/catalogue/handed_merge_test.go @@ -0,0 +1,57 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// issue 831: a moved module's merging file is not handed (ADR 0349 clause 17, the lead's limit): the controller +// fills and merges it as for a module not moved, and the module's other files stay handed. +func TestAMovedModulesMergingFileIsFilledTheOldWayAndItsPlainFileIsHanded(t *testing.T) { + raw := strings.Replace(handedNotifier, `{"id":"legacy"`, + `{"id":"settings","type":"file","path":"${machine:account-home}/.config/notify/settings.json","owner":"${machine:account}","mode":"0644","template":"files/settings.json","merge":"json","takes-config":["width"]}, + {"id":"legacy"`, 1) + moved := strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1) + built := []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64), + Digest: "sha256:" + strings.Repeat("d", 64), + Texts: map[string]string{"rc": notifierText, "settings.json": "{\"theme\": \"dark\"}\n"}}} + cb, _ := composeWith(t, notifierOn(t, held(t, raw, built)), handingTo("store:5000")) + c, got := composeWith(t, notifierOn(t, held(t, moved, built)), handingTo("store:5000")) + var merged, plain map[string]any + for _, r := range c.Resources { + switch r["id"] { + case "notify.settings": + merged = r + case "notify.configuration": + plain = r + } + } + if merged == nil || plain == nil { + t.Fatalf("not composed: %s", got) + } + if text, _ := merged["content"].(string); !strings.Contains(text, `"theme"`) || merged["owner-at-runtime"] != nil { + t.Fatalf("the merging file is not filled the old way: %v", merged) + } + if plain["content"] != nil || plain["owner-at-runtime"] != "owned" { + t.Fatalf("the plain file is not handed: %v", plain) + } + for _, p := range c.Filled { + if strings.HasSuffix(p, "settings.json") { + t.Fatalf("the merging file is named among the filled files: %v", c.Filled) + } + } + if len(c.Plans["notify"].Files) != 1 || c.Plans["notify"].Files[0].ID != "configuration" { + t.Fatalf("the plan is %+v", c.Plans) + } + if len(c.Unplaced) > 0 { + t.Fatalf("unplaced: %v", c.Unplaced) + } + // byte-identical to the not-moved composition + for _, r := range cb.Resources { + if r["id"] == "notify.settings" && !reflect.DeepEqual(r, merged) { + t.Fatalf("the merging file differs from the not-moved composition:\nbefore %v\nnow %v", r, merged) + } + } +} diff --git a/go/catalogue/handed_providers_test.go b/go/catalogue/handed_providers_test.go new file mode 100644 index 0000000..22744f8 --- /dev/null +++ b/go/catalogue/handed_providers_test.go @@ -0,0 +1,274 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// A handed module's plan names the provider serving each provision it requires (design 57 V5a, issue 760): the +// module on the machine resolution chose for this consumer, and the local name it keeps the credential under where +// it keeps several, so the vault, which reads the plan in the assignments mirror, sees a consumer bind and unbind +// (novox/hq ADR 0349 clause 8). One entry per provision and local name, the same entry that names a credential +// the vault keeps for it alone (issue 757). + +// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential +// kept under two local names (ADR 0094) when locals is set. +func notifierConsumer(t *testing.T, moved, locals bool) Manifest { + t.Helper() + raw := handedNotifier + if moved { + raw = notifierMoved() + } + add := `"requires":["postgres-database"],` + if locals { + add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},` + } + raw = strings.Replace(raw, `"build":`, add+`"build":`, 1) + return held(t, raw, builtNotifier()) +} + +// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one. +func twoStores(pin string) World { + return World{Offered: map[string][]Provider{"postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}}, + }}, Pinned: map[string]Chosen{"postgres-database": {Node: pin}}} +} + +func consumerOn(t *testing.T, m Manifest, w World) Resolution { + t.Helper() + node := workstation() + node.At = "workstation.internal" + stores := shelf(m, + Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}, + Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}}) + r, err := Resolve(stores, []string{m.Module}, node, w) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +func TestAHandedModulesPlanNamesTheProviderServingEachProvision(t *testing.T) { + c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")), handingTo("store:5000")) + want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store", + CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")}} + if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) { + t.Fatalf("the plan names %+v, want %+v", got, want) + } + + // **A changed binding changes the plan**: pinned to the other store, the plan names it and not the first. + c, _ = composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("home")), handingTo("store:5000")) + want = []PlanProvision{{Provision: "postgres-database", Provider: "home", ProviderModule: "store-two", + CredentialForm: CredentialFormPair, At: "home.internal", From: "home", As: ConsumerIdentity("workstation", "notify")}} + if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) { + t.Fatalf("rebound, the plan names %+v, want %+v", got, want) + } +} + +// A consumer keeping one provider's credential under several local names is bound once per local name. +func TestAHandedModulesPlanNamesEachLocalName(t *testing.T) { + c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, true), twoStores("anchor")), handingTo("store:5000")) + want := []PlanProvision{ + {Provision: "postgres-database", Local: "cold", Provider: "anchor", ProviderModule: "store", + CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")}, + {Provision: "postgres-database", Local: "hot", Provider: "anchor", ProviderModule: "store", + CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")}, + } + if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) { + t.Fatalf("the plan names %+v, want %+v", got, want) + } +} + +// A module not moved, or moved where it is not handed, composes as before and has no plan. +func TestAConsumerNotHandedComposesAsBefore(t *testing.T) { + _, before := composeWith(t, consumerOn(t, notifierConsumer(t, false, false), twoStores("anchor")), + handingTo("store:5000")) + c, got := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")), + Rendering{Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}) + if got != before { + t.Fatalf("the moved consumer composes differently where it is not handed:\nbefore: %s\nnow: %s", before, got) + } + if c.Plans != nil { + t.Fatalf("a consumer not handed has plans %+v", c.Plans) + } +} + +// A credential the vault keeps for the holder alone is its provision's one entry, with its provider's module too. +func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) { + r := forgeReader(t, true, true) + r.Needs = append(r.Needs, Needed{Name: "s3-bucket", From: "ace", For: "bot", Module: "minio"}, + Needed{Name: "openai", From: "personal", For: "bot", ByRecord: true}) + c, _ := composeWith(t, r, handingTo("store:5000")) + got := c.Plans["bot"].Provisions + if len(got) != 2 || got[0].Provision != "forge-read" || got[0].Name != "forge-read" || + got[0].ProviderModule != "forge" || got[0].Path == "" || + !reflect.DeepEqual(got[1], PlanProvision{Provision: "s3-bucket", Provider: "ace", ProviderModule: "minio", + From: "ace", As: ConsumerIdentity("workstation", "bot")}) { + t.Fatalf("the plan names %+v: one entry per provision, the issued credential's with its name and file, "+ + "none for a licence", got) + } +} + +// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so +// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by +// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a +// person (the offer's own secret issued outside). +func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) { + storeOffering := func(credential string) Manifest { + raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + + credential + `}],"serves":{"postgres-database":{"port":5432}}` + if strings.Contains(credential, `"own"`) { + raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` + + `"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}` + } + return mustParse(t, raw+`}`) + } + for credential, want := range map[string]string{ + ``: CredentialFormPair, + `,"credential":{"own":"admin"}`: CredentialFormShared, + `,"credential":{"own":"token"}`: CredentialFormGiven, + `,"credential":{"issued-by":"outside"}`: CredentialFormGiven, + `,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued, + } { + node := workstation() + node.At = "workstation.internal" + consumer := notifierConsumer(t, true, false) + r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor")) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + c, _ := composeWith(t, r, handingTo("store:5000")) + got := c.Plans["notify"].Provisions + if len(got) != 1 || got[0].CredentialForm != want { + t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want) + } + } + if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" { + t.Errorf("a module offering nothing has the credential form %q", got) + } +} + +// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's +// helper learns a database's name from the bus; a consumer contributing nothing has the entry it had. +func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) { + m := notifierConsumer(t, true, false) + m.Contributes = map[string]map[string]any{"postgres-database": {"name": "nextcloud"}} + c, _ := composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000")) + want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store", + CredentialForm: CredentialFormPair, Contributes: map[string]any{"name": "nextcloud"}, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify"), + // What the binding file says as `${bound:postgres-database:name}` (issue 792). + Names: map[string]string{"name": "nextcloud"}}} + if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) { + t.Fatalf("the plan names %+v, want %+v", got, want) + } + + m = notifierConsumer(t, true, false) + c, _ = composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000")) + raw, err := json.Marshal(c.Plans["notify"].Provisions) + if err != nil { + t.Fatal(err) + } + const before = `[{"provision":"postgres-database","provider":"anchor","provider-module":"store",` + + `"credential-form":"pair","at":"anchor.internal","from":"anchor","as":"mesh_workstation_notify"}]` + if string(raw) != before { + t.Fatalf("a consumer contributing nothing has the entry %s, want %s", raw, before) + } +} + +// A consumer's plan entry carries the names the mesh composed for it through that provision (issue 792): what its +// binding file names as `${bound::name|internal-name|name-|internal-name-}`, so a moved +// provider's helper copies them into the consumer's entry and the client fills the same text. A consumer +// contributing no label has the entry it had. +func TestAHandedModulesPlanEntryCarriesTheNamesComposedForIt(t *testing.T) { + m := notifierConsumer(t, true, false) + m.ContributesMany = map[string]map[string]map[string]any{ + "postgres-database": {"web": {"label": "git"}, "api": {"label": "git-api"}}} + r := consumerOn(t, m, twoStores("anchor")) + r.PublicDomain = "example.org" + c, _ := composeWith(t, r, handingTo("store:5000")) + got := c.Plans["notify"].Provisions + if len(got) != 1 { + t.Fatalf("the plan names %+v", got) + } + want := map[string]string{"name-web": "git.example.org", "name-api": "git-api.example.org"} + own, err := r.ownNames(m, "postgres-database", nil) + if err != nil { + t.Fatal(err) + } + file := map[string]string{} + withOwnNames(file, own) + for k, v := range file { + if strings.HasPrefix(k, "internal-") { + want[k] = v + } + } + if !reflect.DeepEqual(got[0].Names, want) { + t.Fatalf("the entry carries %v, the binding file names %v", got[0].Names, want) + } +} + +// The flag a provides entry carries, `"answered-by": "helper"`, is read and written back, and refused with any other +// word (issue 792). It is no part of moving the module: the flag alone hands none of the module's files, and the +// module composes byte for byte as it did without it. +func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) { + with := strings.Replace(handedNotifier, `"version":"1",`, + `"version":"1","provides":[{"name":"route","scope":"mesh","answered-by":"helper"}],`, 1) + without := strings.Replace(handedNotifier, `"version":"1",`, + `"version":"1","provides":[{"name":"route","scope":"mesh"}],`, 1) + flagged := held(t, with, builtNotifier()) + if flagged.Moved || len(flagged.Provides) != 1 || flagged.Provides[0].AnsweredBy != AnsweredByHelper { + t.Fatalf("the flag was read as %+v (moved %v)", flagged.Provides, flagged.Moved) + } + if flagged.Handed != nil { + t.Fatalf("the flag alone kept what a moved module's build keeps: %+v", flagged.Handed) + } + raw, err := json.Marshal(flagged.Provides[0]) + if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) { + t.Fatalf("written back as %s: %v", raw, err) + } + if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil { + t.Fatal("an unknown answerer was accepted") + } + c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000")) + c2, got := composeWith(t, notifierOn(t, flagged), handingTo("store:5000")) + if got != before || len(c2.Filled) > 0 || c2.Plans != nil || c1.Plans != nil { + t.Fatalf("the flag changed what is composed or handed:\nbefore: %s\nnow: %s\n%v %v", before, got, c2.Filled, c2.Plans) + } +} + +// A plan entry names the tool of an offer whose credential the provider's software issues (issue 928), so the vault +// asks that tool for each holder; an offer of any other form names none. +func TestAHandedModulesPlanNamesTheCredentialToolOfASoftwareIssuedOffer(t *testing.T) { + for credential, want := range map[string]string{ + ``: "", + `,"credential":{"own":"admin"}`: "", + `,"credential":{"issued-by":"outside"}`: "", + `,"credential":{"issued-by":"software","tool":"gitea_agent_token"}`: "gitea_agent_token", + } { + raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + + credential + `}],"serves":{"postgres-database":{"port":5432}}` + if strings.Contains(credential, `"own"`) { + raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"}}` + } + node := workstation() + node.At = "workstation.internal" + consumer := notifierConsumer(t, true, false) + r, err := Resolve(shelf(consumer, mustParse(t, raw+`}`)), []string{consumer.Module}, node, twoStores("anchor")) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + c, _ := composeWith(t, r, handingTo("store:5000")) + got := c.Plans["notify"].Provisions + if len(got) != 1 || got[0].CredentialTool != want { + t.Errorf("an offer with %q: the plan names %+v, want the tool %q", credential, got, want) + } + } +} diff --git a/go/catalogue/handed_pulled_in_test.go b/go/catalogue/handed_pulled_in_test.go new file mode 100644 index 0000000..cfec741 --- /dev/null +++ b/go/catalogue/handed_pulled_in_test.go @@ -0,0 +1,121 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// A module only pulled in by another is handed exactly like an assigned one (issue 765): its templated files, its +// own secrets and its items go to the machine's provisioner client wherever the machine can take them. A module not +// moved composes as before, pulled in or not. + +// pulledInBy is a module that requires what raw's module provides, so resolving it alone pulls that module in. +const pulledInBy = `{"module":"desk","version":"1","requires":["pulled-in-here"]}` + +// providing is raw's module providing what desk requires. +func providing(raw string) string { + return strings.Replace(raw, `"version":"1",`, `"version":"1","provides":[{"name":"pulled-in-here"}],`, 1) +} + +// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone. +func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution { + t.Helper() + desk, err := ParseManifest([]byte(pulledInBy)) + if err != nil { + t.Fatal(err) + } + want := []string{"desk"} + if assigned { + want = append(want, m.Module) + } + r, err := Resolve(shelf(m, desk), want, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + why := map[bool]string{true: "assigned", false: "required by desk"}[assigned] + if r.Because[m.Module] != why { + t.Fatalf("%s is on the workstation because %q, want %q", m.Module, r.Because[m.Module], why) + } + r.Account = "op" + return r +} + +// resourcesOf is the module's resources in a composition, as JSON. +func resourcesOf(t *testing.T, c Composed, module string) string { + t.Helper() + var out []map[string]any + for _, r := range c.Resources { + if strings.HasPrefix(r["id"].(string), module+".") { + out = append(out, r) + } + } + raw, err := json.Marshal(out) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestAModulePulledInIsHandedLikeAnAssignedOne(t *testing.T) { + for name, m := range map[string]struct { + module string + held Manifest + with Rendering + }{ + "its templated file": {"notify", held(t, providing(notifierMoved()), builtNotifier()), handingTo("store:5000")}, + "its own secret": {"bot", held(t, providing(botMoved()), builtBot(botText)), + sealedForBot(handingTo("store:5000"))}, + "its item": {"prompt", held(t, providing(contributorP10k), + builtWith("prompt", map[string]string{"zsh": contributorText})), handingTo("store:5000")}, + } { + assigned, _ := composeWith(t, onWorkstation(t, m.held, true), m.with) + pulled, got := composeWith(t, onWorkstation(t, m.held, false), m.with) + plan, handed := pulled.Plans[m.module] + if !handed { + t.Errorf("%s: a pulled-in moved module is not handed: plans %+v", name, pulled.Plans) + continue + } + if !reflect.DeepEqual(plan, assigned.Plans[m.module]) || !reflect.DeepEqual(pulled.Filled, assigned.Filled) { + t.Errorf("%s: pulled in, its plan is %+v and the filled files %v; assigned, %+v and %v", name, plan, + pulled.Filled, assigned.Plans[m.module], assigned.Filled) + } + if a, p := resourcesOf(t, assigned, m.module), resourcesOf(t, pulled, m.module); a != p { + t.Errorf("%s: pulled in, its resources are\n%s\nassigned,\n%s", name, p, a) + } + if strings.Contains(got, "sealed-to-the-node") || strings.Contains(got, "width = ") { + t.Errorf("%s: the controller filled or sealed something of a handed module: %s", name, got) + } + } +} + +func TestAModulePulledInAndNotMovedComposesAsBefore(t *testing.T) { + for name, m := range map[string]Manifest{ + "a file": held(t, providing(handedNotifier), builtNotifier()), + "a secret": held(t, providing(handedBot), builtBot(botText)), + } { + _, before := composeWith(t, onWorkstation(t, m, false), sealedForBot(Rendering{ArtifactStore: "store:5000"})) + c, got := composeWith(t, onWorkstation(t, m, false), sealedForBot(handingTo("store:5000"))) + if got != before || c.Plans != nil || len(c.Filled) > 0 { + t.Errorf("%s: a pulled-in module not moved composes differently where the machine hands:\nbefore: %s\nnow: %s"+ + "\nplans %v, filled %v", name, before, got, c.Plans, c.Filled) + } + } +} + +// What requires a module pulled in is said by name, for its value in the assignments mirror; nothing for one +// assigned. +func TestAPulledInModuleSaysWhatRequiresIt(t *testing.T) { + m := held(t, providing(notifierMoved()), builtNotifier()) + if by := onWorkstation(t, m, false).RequiredBy("notify"); by != "desk" { + t.Errorf("pulled in, notify is required by %q, want desk", by) + } + assigned := onWorkstation(t, m, true) + if by := assigned.RequiredBy("notify"); by != "" { + t.Errorf("assigned, notify is required by %q", by) + } + if by := assigned.RequiredBy("absent"); by != "" { + t.Errorf("a module not on the machine is required by %q", by) + } +} diff --git a/go/catalogue/handed_secrets_test.go b/go/catalogue/handed_secrets_test.go new file mode 100644 index 0000000..5d06214 --- /dev/null +++ b/go/catalogue/handed_secrets_test.go @@ -0,0 +1,252 @@ +package catalogue + +import ( + "reflect" + "slices" + "strings" + "testing" +) + +// A moved module's own secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647): +// where the module is handed, its declaration carries no sealed value — each file holding a secret alone is placed +// without one and filled by the client, and the plan names every secret its templates and those files need, by +// name. Where it is not handed, and for every module not moved, the composition is byte for byte what it was. + +const ( + handedBot = `{"module":"bot","version":"1", + "own-secrets":{"token":"${dir:state}/token"}, + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"state","type":"directory","mode":"0700","place":"."}, + {"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"}, + {"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}` + botText = "token = ${secret:token}\nhome = ${machine:account-home}\n" +) + +func botMoved() string { + return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1) +} + +func builtBot(text string) []Built { + return []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64), + Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}} +} + +// What the controller seals for the bot when it fills it the old way: a stand-in sealed value. +func sealedForBot(with Rendering) Rendering { + with.Needed = map[string]map[string]string{"bot": {"token": "sealed-to-the-node"}} + return with +} + +func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) { + r := notifierOn(t, held(t, botMoved(), builtBot(botText))) + c, got := composeWith(t, r, handingTo("store:5000")) + if strings.Contains(got, "sealed") || strings.Contains(got, "${secret:") { + t.Fatalf("a handed module's declaration carries a sealed value or a secret placeholder: %s", got) + } + var token map[string]any + for _, res := range c.Resources { + if res["id"] == "bot.needs-token" { + token = res + } + } + if token == nil { + t.Fatalf("the token's file is not declared: %s", got) + } + if token["path"] == nil || strings.Contains(token["path"].(string), "${") || token["mode"] != "0600" || + token["owner-at-runtime"] != "owned" || token[HandedSecretField] != nil { + t.Fatalf("the token's file is declared %v: placed, 0600, owned at runtime, no value", token) + } + path := token["path"].(string) + if !reflect.DeepEqual(c.Filled, []string{path, "/etc/bot/bot.conf"}) { + t.Fatalf("the filled files are %v", c.Filled) + } + if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) { + t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want) + } + // A process naming the token's file still restarts when its client writes it. + for _, res := range c.Resources { + if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") { + t.Errorf("the daemon does not restart on its token's file: %v", res["restart-on"]) + } + } +} + +// A secret only a template names is in the plan by name alone. +func TestASecretOnlyATemplateNamesIsPlannedByName(t *testing.T) { + raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"},`, + `"own-secrets":{"token":"${dir:state}/token","api-key":"${dir:state}/api-key"},`, 1) + r := notifierOn(t, held(t, raw, builtBot(botText+"key = ${secret:api-key}\n"))) + c, _ := composeWith(t, r, handingTo("store:5000")) + names := []string{} + for _, s := range c.Plans["bot"].Secrets { + names = append(names, s.Name) + } + if strings.Join(names, " ") != "api-key token" { + t.Fatalf("the plan names %v", c.Plans["bot"].Secrets) + } +} + +// Nothing changes for a module that has not moved, nor for a moved one where it is not handed: its secrets are +// sealed into the declaration as before. +func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) { + _, before := composeWith(t, notifierOn(t, held(t, handedBot, builtBot(botText))), + sealedForBot(handingTo("store:5000"))) + if !strings.Contains(before, "sealed-to-the-node") { + t.Fatalf("the unmoved module is not sealed the old way: %s", before) + } + for name, with := range map[string]Rendering{ + "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + } { + c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with)) + if got != before { + t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s", + name, before, got) + } + if len(c.Filled) > 0 || c.Plans != nil { + t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans) + } + } +} + +func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) { + // A template naming a credential the module requires: a binding's, which moves with its provider. + requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1) + r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")), + Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}), + []string{"bot"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") { + t.Errorf("a template naming a required credential: %v", err) + } + // A file with its text inline naming a secret, in a handed module. + inline := strings.Replace(botMoved(), `{"id":"daemon"`, + `{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1) + r = notifierOn(t, held(t, inline, builtBot(botText))) + if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") { + t.Errorf("an inline file naming a secret in a handed module: %v", err) + } + // A family and a secret issued outside the mesh are not handed by name yet: the mark is refused. + for why, raw := range map[string]string{ + "a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1), + "issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1), + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") { + t.Errorf("a moved module with %s: %v", why, err) + } + } +} + +// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read +// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared +// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a +// credential the mesh makes, the composition is what it was. +func forgeReader(t *testing.T, moved bool, issued bool) Resolution { + t.Helper() + raw := strings.Replace(handedBot, `"own-secrets":`, + `"requires":[{"name":"forge-read","optional":true}],"secrets":{"forge-read":"${dir:state}/forge.secret"},"own-secrets":`, 1) + if moved { + raw = strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1) + } + r := notifierOn(t, held(t, raw, builtBot(botText))) + r.Needs = append(r.Needs, Needed{Name: "forge-read", From: "novox", For: "bot", Module: "forge", + Sealed: "sealed-forge-read", IssuedToHolder: issued}) + return r +} + +func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) { + c, got := composeWith(t, forgeReader(t, true, true), handingTo("store:5000")) + if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) { + t.Fatalf("a handed module's declaration carries a sealed value or the marker: %s", got) + } + var file map[string]any + for _, res := range c.Resources { + if res["id"] == "bot."+SecretID("forge-read") { + file = res + } + } + if file == nil { + t.Fatalf("the credential's file is not declared: %s", got) + } + path, _ := file["path"].(string) + if path == "" || strings.Contains(path, "${") || file["mode"] != "0600" || file["owner-at-runtime"] != "owned" { + t.Fatalf("the credential's file is declared %v: placed, 0600, owned at runtime, no value", file) + } + if !strings.Contains(strings.Join(c.Filled, " "), path) { + t.Fatalf("the credential's file is not among the filled files %v", c.Filled) + } + want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge", + Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}} + if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) { + t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want) + } +} + +func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) { + _, before := composeWith(t, forgeReader(t, false, false), sealedForBot(handingTo("store:5000"))) + if !strings.Contains(before, "sealed-forge-read") { + t.Fatalf("the unmoved module's credential is not sealed the old way: %s", before) + } + for why, r := range map[string]Resolution{ + "a module not moved": forgeReader(t, false, true), + "a moved module whose credential is made": forgeReader(t, true, false), + } { + c, got := composeWith(t, r, sealedForBot(handingTo("store:5000"))) + if !strings.Contains(got, "sealed-forge-read") { + t.Errorf("%s: the credential is not sealed the old way: %s", why, got) + } + // Its provider is named (issue 760), and no credential for the client to fill. + if p, ok := c.Plans["bot"]; ok && slices.ContainsFunc(p.Provisions, func(pr PlanProvision) bool { + return pr.Name != "" || pr.Path != "" + }) { + t.Errorf("%s: the plan names a credential to fill: %+v", why, p.Provisions) + } + } + c, got := composeWith(t, forgeReader(t, true, true), sealedForBot(Rendering{Provisioner: "mesh-provisioner-client", + ArtifactStore: "store:5000"})) + if !strings.Contains(got, "sealed-forge-read") || c.Plans != nil { + t.Errorf("where the engine does not take a filled file alone, the credential is not sealed the old way: %s", got) + } +} + +// A handed consumer's own login from a provider that gives each consumer one (issue 779): the same entry as for an +// issued credential, with the provider's form on it, and nothing sealed; where the consumer is not handed, the +// login is sealed as it was. +func TestAHandedConsumersOwnLoginFromAPerLoginProviderIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) { + pair := func(handed bool) Resolution { + r := forgeReader(t, true, handed) + r.Needs[len(r.Needs)-1].CredentialForm = CredentialFormPair + return r + } + c, got := composeWith(t, pair(true), handingTo("store:5000")) + if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) { + t.Fatalf("a handed consumer's declaration carries its login sealed or the marker: %s", got) + } + var path string + for _, res := range c.Resources { + if res["id"] == "bot."+SecretID("forge-read") { + path, _ = res["path"].(string) + if res["mode"] != "0600" || res["owner-at-runtime"] != "owned" || res["content"] != nil || res["sealed"] != nil { + t.Fatalf("the login's file is declared %v: placed, 0600, owned at runtime, no value", res) + } + } + } + if path == "" || !strings.Contains(strings.Join(c.Filled, " "), path) { + t.Fatalf("the login's file %q is not declared and filled: %v", path, c.Filled) + } + want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge", + CredentialForm: CredentialFormPair, Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}} + if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) { + t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want) + } + // Not marked as the vault's, the login is sealed as before. + _, before := composeWith(t, pair(false), sealedForBot(handingTo("store:5000"))) + if !strings.Contains(before, "sealed-forge-read") { + t.Fatalf("a consumer whose login the vault does not keep for it has it sealed no more: %s", before) + } +} diff --git a/go/catalogue/handed_test.go b/go/catalogue/handed_test.go new file mode 100644 index 0000000..dbe3bec --- /dev/null +++ b/go/catalogue/handed_test.go @@ -0,0 +1,188 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// A module marked moved hands its templated files to the provisioner (novox/hq ADR 0349 §4, §6, §17, design 57 P6, +// issue 672): on a machine whose node-engine takes a filled file on the client's word and that runs a client, each +// is declared placed and without its text, named among the filled files, and planned for the client; anywhere +// else, and for every module not marked, the composition is byte for byte what it was. + +const ( + handedNotifier = `{"module":"notify","version":"1", + "config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}}, + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"}, + {"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"}, + {"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"}, + {"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}` + notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n" +) + +func notifierMoved() string { + return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1) +} + +func builtNotifier() []Built { + return []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64), + Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}} +} + +func notifierOn(t *testing.T, m Manifest) Resolution { + t.Helper() + r, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +func composeWith(t *testing.T, r Resolution, with Rendering) (Composed, string) { + t.Helper() + c, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(c.Resources) + if err != nil { + t.Fatal(err) + } + return c, string(raw) +} + +func handingTo(store string) Rendering { + return Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store} +} + +func TestAMovedModulesBuildKeepsWhatTheProvisionerNeeds(t *testing.T) { + m := held(t, notifierMoved(), builtNotifier()) + want := &Handed{Source: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64), + Digest: "sha256:" + strings.Repeat("d", 64), Templates: map[string]string{"configuration": "rc"}} + if !reflect.DeepEqual(m.Handed, want) { + t.Fatalf("the build kept %+v, want %+v", m.Handed, want) + } + if notMoved := held(t, handedNotifier, builtNotifier()); notMoved.Handed != nil { + t.Fatalf("a module not marked moved kept %+v", notMoved.Handed) + } +} + +// Nothing changes for a module that has not moved, nor for a moved one where it cannot be handed. +func TestAMovedModuleComposesAsBeforeWhereItIsNotHanded(t *testing.T) { + _, before := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000")) + for name, with := range map[string]Rendering{ + "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + } { + c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), with) + if got != before { + t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s", + name, before, got) + } + if len(c.Filled) > 0 || c.Plans != nil { + t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans) + } + } + if !strings.Contains(before, `width = 250`) { + t.Fatalf("the unmarked module is not filled the old way: %s", before) + } +} + +func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) { + c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), handingTo("store:5000")) + var file, legacy, dir map[string]any + for _, r := range c.Resources { + switch r["id"] { + case "notify.configuration": + file = r + case "notify.legacy": + legacy = r + case "notify.configuration-dir": + dir = r + } + } + if file == nil || legacy == nil || dir == nil { + t.Fatalf("not composed: %s", got) + } + wantFile := map[string]any{"id": "notify.configuration", "type": "file", "path": "/home/op/.config/notify/rc", + "owner": "op", "mode": "0644", "owner-at-runtime": "owned"} + if !reflect.DeepEqual(file, wantFile) { + t.Fatalf("the handed file is declared %v, want %v (placed, no text, owned at runtime)", file, wantFile) + } + if legacy["content"] != "plain = yes\n" || legacy["owner-at-runtime"] != nil { + t.Fatalf("a file with its text inline is not composed the old way: %v", legacy) + } + if dir["path"] != "/home/op/.config/notify" { + t.Fatalf("the module's other resources are not placed as before: %v", dir) + } + if strings.Contains(got, "width = ") { + t.Fatalf("the controller filled the handed file: %s", got) + } + if !reflect.DeepEqual(c.Filled, []string{"/home/op/.config/notify/rc"}) { + t.Fatalf("filled files %v", c.Filled) + } + want := map[string]Plan{"notify": { + Templates: PlanTemplates{Source: "http://store:5000/v2/notify/files/blobs/sha256:" + strings.Repeat("d", 64), + Digest: "sha256:" + strings.Repeat("d", 64)}, + Files: []PlanFile{{ID: "configuration", Path: "/home/op/.config/notify/rc", Template: "rc", + OwnerAtRuntime: "owned", Takes: "reload-on"}}, + Placement: map[string]string{"machine:account-home": "/home/op"}, + }} + if !reflect.DeepEqual(c.Plans, want) { + t.Fatalf("the plans are %+v, want %+v", c.Plans, want) + } +} + +func TestAMovedModulesMarkIsJudged(t *testing.T) { + for name, raw := range map[string]string{ + "marked moved and naming no template": `{"module":"bare","version":"1","moved":true, + "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, + "handed written in a repository": `{"module":"bare","version":"1","moved":true, + "handed":{"source":"s","digest":"d","templates":{}}, + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`, + "handed and not moved": `{"module":"bare","version":"1", + "handed":{"source":"s","digest":"d","templates":{}}, + "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +// What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still +// marked moved, and composes as before, since nothing of its build's archive is there to hand. +func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil { + t.Fatal(err) + } + m, err := ParseManifest([]byte(notifierMoved())) + if err != nil { + t.Fatal(err) + } + filled, err := WithTemplatesFrom(m, dir) + if err != nil { + t.Fatalf("a moved module read from its tree is refused: %v", err) + } + if !filled.Moved || filled.Handed != nil { + t.Fatalf("read from its tree: moved %v, handed %+v", filled.Moved, filled.Handed) + } + _, got := composeWith(t, notifierOn(t, filled), handingTo("store:5000")) + _, want := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000")) + if got != want { + t.Fatalf("read from its tree it composes differently:\nwant: %s\ngot: %s", want, got) + } +} diff --git a/go/catalogue/health_test.go b/go/catalogue/health_test.go new file mode 100644 index 0000000..c5a68de --- /dev/null +++ b/go/catalogue/health_test.go @@ -0,0 +1,217 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-host/validate" +) + +// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §8): `module +// check` refuses each part out of its bounds, each endpoint named by a port or an address, a tool the +// module does not serve, and a tool check alone — a test per refusal. + +const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// healthManifest is a module of one web container, one running service and one step, with the health +// given on the container (or on the resource named by on). +func healthManifest(t *testing.T, on string, health map[string]any, more ...map[string]any) []byte { + t.Helper() + resources := []map[string]any{ + {"id": "server", "type": "container", "name": "app-server", "image": "registry.example/app" + healthDigest, + "ports": []any{"8080"}}, + {"id": "daemon", "type": "service", "unit": "app.service", "state": "running"}, + {"id": "seed", "type": "container", "name": "app-seed", "image": "registry.example/app" + healthDigest, + "run-once": true}, + {"id": "sweep", "type": "container", "name": "app-sweep", "image": "registry.example/app" + healthDigest, + "schedule": "0 3 * * *"}, + } + resources = append(resources, more...) + for _, r := range resources { + if r["id"] == on && health != nil { + r["health"] = health + } + } + m := map[string]any{"module": "app", "requires": []any{"postgres-database"}, "tools": []any{"app_status"}, + "listens": []any{ + map[string]any{"name": "web", "port": 8080, "from": "mesh"}, + map[string]any{"name": "beacon", "port": 9999, "protocol": "udp", "from": "mesh"}, + }, + "resources": resources} + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestAWellFormedHealthIsAccepted(t *testing.T) { + for _, h := range []map[string]any{ + {"kind": "http", "endpoint": "web", "path": "/healthz", "status": 200, "body": "ok", "needs": "postgres-database"}, + {"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "2s", "looks": 2, "grace": "0s"}, + {"kind": "runtime"}, + {"kind": "exec", "command": "pg_isready -q", "grace": "4m", "looks": 2, "interval": "30s"}, + } { + if _, err := ParseManifest(healthManifest(t, "server", h)); err != nil { + t.Errorf("%v was refused: %v", h, err) + } + } + if _, err := ParseManifest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil { + t.Errorf("a service's own readiness was refused: %v", err) + } + // A tool beside a check of another kind on the module. + raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"}, + map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", + "health": map[string]any{"kind": "tool", "tool": "app_status"}}) + if _, err := ParseManifest(raw); err != nil { + t.Errorf("a tool check beside an http check was refused: %v", err) + } +} + +func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) { + cases := []struct { + name string + on string + health map[string]any + says string + }{ + {"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"}, + {"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`}, + {"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"}, + {"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"}, + {"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"}, + {"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`}, + {"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"}, + {"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"}, + {"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"}, + {"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"}, + {"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"}, + {"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"}, + {"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"}, + {"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"}, + {"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"}, + {"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"}, + {"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"}, + {"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"}, + {"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"}, + {"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"}, + {"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"}, + {"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"}, + {"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"}, + {"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"}, + {"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"}, + {"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"}, + {"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"}, + {"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"}, + {"health that is not an object", "server", nil, "is an object"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + raw := healthManifest(t, c.on, c.health) + if c.health == nil { + raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1)) + } + _, err := ParseManifest(raw) + if err == nil { + t.Fatalf("%s was accepted", c.name) + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("refused, but not for saying %q: %v", c.says, err) + } + }) + } +} + +func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) { + m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz", + "needs": "postgres-database"})) + if err != nil { + t.Fatal(err) + } + compose := func(with Rendering) map[string]any { + out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + at := indexOfID(out, "app.server") + if at < 0 { + t.Fatal("the container was lost") + } + return out[at] + } + // An engine older than the field is not sent it: it would refuse the whole declaration. + if h, sent := compose(Rendering{Ports: map[string]map[int]int{"app": {8080: 31001}}})["health"]; sent { + t.Fatalf("health was sent to an engine that does not read it: %v", h) + } + got := compose(Rendering{ReadsHealth: true, Ports: map[string]map[int]int{"app": {8080: 31001}}})["health"].(map[string]any) + if got["port"] != 31001 || got["endpoint"] != "web" || got["path"] != "/healthz" || got["needs"] != "postgres-database" { + t.Errorf("composed as %v", got) + } + if got["interval"] != "30s" || got["timeout"] != "5s" || got["looks"] != 3 || got["grace"] != "1m0s" { + t.Errorf("the defaults were not written out: %v", got) + } + // The port this machine gives the endpoint moves, and the check moves with it. + moved := compose(Rendering{ReadsHealth: true, Ports: map[string]map[int]int{"app": {8080: 31002}}})["health"].(map[string]any) + if moved["port"] != 31002 { + t.Errorf("after the port moved the check still dials %v", moved["port"]) + } + // And the catalogue's manifest is untouched by composing it. + if _, ok := m.Resources[0]["health"].(map[string]any)["port"]; ok { + t.Error("composing wrote the port into the catalogue's own manifest") + } +} + +func TestTheUndeclaredAreTheLongRunningWithoutHealth(t *testing.T) { + m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "runtime"})) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(Undeclared(m), ","); got != "daemon" { + t.Errorf("undeclared: %q; the step and the schedule are not long-running, the server declares", got) + } +} + +func TestATooledHealthIsGrantedToTheEngine(t *testing.T) { + raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"}, + map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", + "health": map[string]any{"kind": "tool", "tool": "app_status"}}) + m, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + if got := HealthChecks(m); len(got) != 1 || got[0] != "app.app_status" { + t.Errorf("checks %v", got) + } +} + +// What the controller composes the node-engine takes: every kind, composed for an engine that reads it, +// passes the engine's own validator (mesh-host/validate) — one set of words on both sides. +func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) { + for _, h := range []map[string]any{ + {"kind": "http", "endpoint": "web", "path": "/healthz", "status": 200, "body": "ok", "needs": "postgres-database"}, + {"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "2s", "looks": 2, "grace": "0s"}, + {"kind": "runtime"}, + {"kind": "exec", "command": "pg_isready -q"}, + } { + m, err := ParseManifest(healthManifest(t, "server", h, + map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", + "health": map[string]any{"kind": "tool", "tool": "app_status"}})) + if err != nil { + t.Fatal(err) + } + m.Resources[1]["health"] = map[string]any{"kind": "unit"} + out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(Rendering{ReadsHealth: true, + Ports: map[string]map[int]int{"app": {8080: 31001}}}) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Errorf("%v composed into something the node-engine refuses: %v", h, problems) + } + } +} diff --git a/go/catalogue/holdings_test.go b/go/catalogue/holdings_test.go new file mode 100644 index 0000000..0159490 --- /dev/null +++ b/go/catalogue/holdings_test.go @@ -0,0 +1,194 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// **A seat's holder on record settles who holds it, and lets the next holder stand beside the +// current one** (novox/hq ADR 0131, design 28 task 5.3). Until the record existed, two assignments +// whose modules both claimed a seat were refused outright — which left no way to hand a seat over +// without a moment where nobody held it, and the control plane finds its own bus through one of +// these seats. That moment was the outage of 2026-09-27. + +func busSeatDelivering(t *testing.T, delivers string) { + t.Helper() + was := Seats() + t.Cleanup(func() { UseSeats(was) }) + UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}}) +} + +func oldBroker() Manifest { + return Manifest{Module: "old-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} +} + +func newBroker() Manifest { + return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} +} + +// Nothing on record: exactly the old rule. One claimant holds; two are refused. +func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + node := Node{Name: "anchor"} + + held, problems := checkClaims([]Manifest{oldBroker()}, node, nil, nil) + if len(problems) != 0 || len(held) != 1 || held[0].Module != "old-broker" { + t.Fatalf("a sole claimant did not hold the seat: held=%v problems=%v", held, problems) + } + _, problems = checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, nil) + if len(problems) != 1 || !strings.Contains(problems[0], "both claim") { + t.Fatalf("two claimants with nothing on record were not refused: %v", problems) + } +} + +// With a holder on record, the other eligible assignment is silent: not refused, and not holding. +func TestTheHolderOnRecordHoldsAndTheOtherClaimantStandsBesideIt(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + node := Node{Name: "anchor"} + record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}} + + held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, record) + if len(problems) != 0 { + t.Fatalf("the assignment beside the holder was refused: %v", problems) + } + if len(held) != 1 || held[0].Module != "new-broker" { + t.Fatalf("the holder on record is not the one holding: %v", held) + } +} + +// The record names a node too: an eligible module on another machine holds nothing, and its +// machine's set still resolves. +func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}} + + held, problems := checkClaims([]Manifest{oldBroker()}, Node{Name: "laptop"}, nil, record) + if len(problems) != 0 || len(held) != 0 { + t.Fatalf("a claimant elsewhere than the recorded holder was not simply silent: held=%v problems=%v", + held, problems) + } +} + +// A record naming a seat's former name still applies to it after a rename (ADR 0122). +func TestAHolderRecordedUnderAFormerNameStillHolds(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + wasAliases := aliases + t.Cleanup(func() { UseAliases(wasAliases) }) + UseAliases(map[string]string{"the-broker": "mesh-broker"}) + record := []Held{{Claim: "the-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}} + + held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, Node{Name: "anchor"}, nil, record) + if len(problems) != 0 || len(held) != 1 || held[0].Module != "new-broker" { + t.Fatalf("a record under the former name did not settle the seat: held=%v problems=%v", held, problems) + } +} + +// CanHold is the one judgement registration and the handover share, against the store's row. +func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + seat, _ := SeatNamed("mesh-broker") + + if err := CanHold(newBroker(), seat); err != nil { + t.Fatalf("a module that claims the seat and provides what it delivers was refused: %v", err) + } + noClaim := Manifest{Module: "quiet", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}} + if err := CanHold(noClaim, seat); err == nil || !strings.Contains(err.Error(), "does not claim") { + t.Fatalf("a module that never claimed the seat was allowed to hold it: %v", err) + } + wrongScope := newBroker() + wrongScope.Claims[0].Scope = ScopeNode + if err := CanHold(wrongScope, seat); err == nil || !strings.Contains(err.Error(), "scope") { + t.Fatalf("a claim at the wrong scope was allowed: %v", err) + } + cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} + if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) { + t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err) + } + // A holder's own tools need not be the seat's verbs: the claim may name what it serves for the + // role (ADR 0160), and then only those count — and only the seat's verbs may be named. + promising := seat + promising.Serves = []Verb{{Name: "databases"}, {Name: "query"}} + engine := newBroker() + engine.Tools = []string{"engine_list_databases", "engine_query"} + if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve databases, query") { + t.Fatalf("a holder whose tools are not the seat's verbs was allowed without saying what it serves: %v", err) + } + engine.Claims[0].Serves = []string{"databases", "query"} + if err := CanHold(engine, promising); err != nil { + t.Fatalf("a claim naming the seat's verbs was refused: %v", err) + } + engine.Claims[0].Serves = []string{"databases"} + if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve query") { + t.Fatalf("a claim naming half the verbs was allowed: %v", err) + } + engine.Claims[0].Serves = []string{"databases", "query", "engine_query"} + if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Fatalf("a claim naming a verb the seat never promised was allowed: %v", err) + } + // And the judgement follows the store's row, not a compiled copy. + busSeatDelivering(t, "amqp") + seat, _ = SeatNamed("mesh-broker") + if err := CanHold(cannotAnswer, seat); err != nil { + t.Fatalf("with the row saying amqp, an amqp provider was refused: %v", err) + } +} + +// A machine being moved is handed its membership for the new bus as a sealed file in its own +// declaration — the machine's, not any module's (design 28, task 5.2). +func TestAMembershipForTheNewBusIsComposedAsASealedFile(t *testing.T) { + r := Resolution{Node: "anchor"} + got, err := r.Compose(Rendering{BusMembership: "sealed-blob"}) + if err != nil { + t.Fatal(err) + } + var found map[string]any + for _, res := range got.Resources { + if res["id"] == BusMembershipID() { + found = res + } + } + if found == nil { + t.Fatalf("no membership resource in %v", got.Resources) + } + if found["path"] != BusMembershipPath || found["sealed"] != "sealed-blob" || found["mode"] != "0600" { + t.Fatalf("the membership is not a sealed 0600 file where the host reads it: %v", found) + } + // And a machine not being moved is handed nothing. + got, _ = r.Compose(Rendering{}) + for _, res := range got.Resources { + if res["id"] == BusMembershipID() { + t.Fatal("a machine with no membership on record was handed one") + } + } +} + +// The store's seat rows have no protocol columns yet; loading them must not drop the protocol the +// bus is derived from, or no role's work queue is ever raised (found live, 2026-09-28). +func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) { + was := Seats() + t.Cleanup(func() { UseSeats(was) }) + UseSeats([]Seat{{Name: "mesh-build-machine", Scope: ScopeMesh, Decision: "row"}}) + got, ok := SeatNamed("mesh-build-machine") + if !ok || len(got.Accepts) == 0 { + t.Fatalf("the build machine's seat lost what it accepts when loaded from the store: %+v", got) + } + if got.Decision != "row" { + t.Fatalf("the store's own columns were not kept: %+v", got) + } +} + +func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + elsewhere := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "old-broker"}} + + _, problems := checkClaims([]Manifest{newBroker()}, Node{Name: "laptop"}, elsewhere, nil) + if len(problems) != 1 { + t.Fatalf("two derived claimants across machines were not refused: %v", problems) + } + if !strings.Contains(problems[0], "`seat mesh-broker --to anchor/old-broker`") { + t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0]) + } +} diff --git a/go/catalogue/holds_test.go b/go/catalogue/holds_test.go new file mode 100644 index 0000000..2a39fb7 --- /dev/null +++ b/go/catalogue/holds_test.go @@ -0,0 +1,100 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// novox/hq ADR 0377 §3: a move under a module's own `record` rolls out when it recreates none of its containers +// and moves none of its data, judged from the two builds' manifests. +func TestWhatAMoveUnderRecordHoldsFor(t *testing.T) { + base := func() Manifest { + raw := `{"module":"mailu","tools":["mailu_status"], + "requires":["postgres-database"], + "data":{"own":[{"id":"mail","path":"${dir:data-mail}","class":"valuable"}]}, + "bundles":[{"name":"tools","source":"store/mailu-tools@sha256:aaa","digest":"sha256:aaa"}], + "resources":[ + {"id":"state","type":"directory"}, + {"id":"mesh-state","type":"directory"}, + {"id":"data-mail","type":"directory"}, + {"id":"config-env","type":"file","path":"${dir:state}/mailu.env","content":"A=1"}, + {"id":"runtime-config","type":"file","path":"${dir:mesh-state}/config.json","content":"{}"}, + {"id":"inbox-note","type":"file","path":"${dir:data-mail}/README","content":"x"}, + {"id":"imap","type":"container","image":"registry/imap@sha256:1","volumes":["${dir:data-mail}:/mail"], + "env-file":["${dir:state}/mailu.env"]}, + {"id":"watcher","type":"process","command":"watch"}, + {"id":"installation","type":"archive","path":"/opt/mailu-tools","artifact":"archive"}, + {"id":"guard","type":"package","package":"nftables"}]}` + var m Manifest + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + return m + } + resource := func(m *Manifest, id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil + } + cases := []struct { + name string + change func(m *Manifest) + holds string // empty: it rolls out + }{ + {"nothing", func(*Manifest) {}, ""}, + {"a tools bundle", func(m *Manifest) { + m.Bundles[0].Digest, m.Bundles[0].Source = "sha256:bbb", "store/mailu-tools@sha256:bbb" + }, ""}, + {"the tool list", func(m *Manifest) { m.Tools = append(m.Tools, "mailu_queue") }, ""}, + {"a file no container mounts", func(m *Manifest) { resource(m, "runtime-config")["content"] = `{"a":1}` }, ""}, + {"a container's image", func(m *Manifest) { resource(m, "imap")["image"] = "registry/imap@sha256:2" }, "recreates 1 of mailu's 1"}, + {"a container's declaration", func(m *Manifest) { resource(m, "imap")["health"] = map[string]any{"kind": "runtime"} }, "recreates 1"}, + {"a container added", func(m *Manifest) { + m.Resources = append(m.Resources, map[string]any{"id": "smtp", "type": "container", "image": "x"}) + }, "recreates"}, + {"a mounted file", func(m *Manifest) { resource(m, "config-env")["content"] = "A=2" }, "which its container imap mounts"}, + {"a file within declared data", func(m *Manifest) { resource(m, "inbox-note")["content"] = "y" }, "which its container imap mounts"}, + {"a directory holding declared data", func(m *Manifest) { resource(m, "data-mail")["mode"] = "0700" }, "data-mail"}, + {"the data declaration", func(m *Manifest) { m.Data.Own[0].Class = ClassIrreplaceable }, "data declaration"}, + {"a provider", func(m *Manifest) { m.Requires = append(m.Requires, "s3") }, "what it requires"}, + {"what it provides", func(m *Manifest) { m.Provides = append(m.Provides, Offer{Name: "smtp"}) }, "what it provides"}, + {"a process of a module with data", func(m *Manifest) { resource(m, "watcher")["command"] = "watch2" }, "long-running process watcher"}, + {"an archive resource", func(m *Manifest) { resource(m, "installation")["artifact"] = "archive2" }, "the archive installation"}, + {"a package resource", func(m *Manifest) { resource(m, "guard")["package"] = "nftables2" }, "the package guard"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + from, to := base(), base() + c.change(&to) + got := Holds(from, to) + switch { + case c.holds == "" && got != "": + t.Fatalf("held: %s", got) + case c.holds != "" && !strings.Contains(got, c.holds): + t.Fatalf("holds %q; want it to say %q", got, c.holds) + } + }) + } + // A data directory nothing mounts still holds its data. + from, to := base(), base() + from.Data.Own = append(from.Data.Own, DataItem{ID: "archive", Path: "${dir:mesh-state}/archive", Class: "valuable"}) + to.Data = &Data{Own: append([]DataItem(nil), from.Data.Own...)} + resource(&to, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json" + resource(&from, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json" + resource(&to, "runtime-config")["content"] = "new" + if got := Holds(from, to); !strings.Contains(got, "lies within its data archive") { + t.Fatalf("a file within data nothing mounts: %q", got) + } + // A process of a module with no data of its own may change. + from, to = base(), base() + from.Data, to.Data = nil, nil + resource(&to, "watcher")["command"] = "watch2" + if got := Holds(from, to); got != "" { + t.Fatalf("a process of a module with no data held: %s", got) + } +} diff --git a/go/catalogue/identity_bound_test.go b/go/catalogue/identity_bound_test.go new file mode 100644 index 0000000..f7f27c0 --- /dev/null +++ b/go/catalogue/identity_bound_test.go @@ -0,0 +1,190 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// Each test names the decision it defends: novox/hq ADR 0225, which refines ADR 0049 after issue 263. + +// The provision a module requires sets the bound on its identity, not the tightest backend anywhere. +func TestABoundIsTheProvisionsOwn(t *testing.T) { + store := Manifest{Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}} + database := Manifest{Module: "db", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 63, In: "a PostgreSQL role"}}}, + Receives: map[string]string{"postgres-database": "/var/lib/mesh/db/mesh.json"}} + if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 || b.In != "an S3 access key" { + t.Errorf("an object store's stated bound was not taken: %+v", b) + } + if b := database.IdentityBoundOf("postgres-database"); b.Max != 63 { + t.Errorf("a database's stated bound was not taken: %+v", b) + } + // mesh_workstation_keycloak is 25: refused by the object store, accepted by the database. + if CheckIdentityWithin("workstation", "keycloak", store.IdentityBoundOf("s3-bucket")) == nil { + t.Error("a 25-character identity fit a 20-character access key") + } + if err := CheckIdentityWithin("workstation", "keycloak", database.IdentityBoundOf("postgres-database")); err != nil { + t.Errorf("a database consumer paid the object store's limit: %v", err) + } +} + +// What an offer leaves unsaid follows from whether its provider can keep a name at all. +func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) { + // Told nothing about its consumers — no receives, nothing served from their identity: no bound. + // The resolver provision is exactly this, and its consumers paid an object store's limit (263). + resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}} + if b := resolver.IdentityBoundOf("wildcard-resolution"); b.Bounded() { + t.Errorf("a provision that is told nothing of its consumers bounds them: %+v", b) + } + // Told each consumer and silent about its backend: the old global bound, not none. + told := Manifest{Module: "told", Provides: []Offer{{Name: "thing", Scope: ScopeMesh}}, + Receives: map[string]string{"thing": "/var/lib/mesh/told/mesh.json"}} + if b := told.IdentityBoundOf("thing"); b.Max != DefaultIdentityLimit { + t.Errorf("a provider told its consumers and silent about its backend is not held to %d: %+v", + DefaultIdentityLimit, b) + } + // Serving a value built from the identity is being told it, too. + serving := Manifest{Module: "serving", Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{"bucket": {"name": "b-${consumer:as:dns}"}}} + if b := serving.IdentityBoundOf("bucket"); b.Max != DefaultIdentityLimit { + t.Errorf("a provider deriving a name from its consumers is not bounded: %+v", b) + } + // And `false` says it outright, even for a provider that receives. + routes := Manifest{Module: "routes", Provides: []Offer{{Name: "route", Scope: ScopeMesh, + Identity: &OfferIdentity{None: true}}}, + Receives: map[string]string{"route": "/var/lib/mesh/routes/mesh.json"}} + if b := routes.IdentityBoundOf("route"); b.Bounded() { + t.Errorf("`identity: false` still bounds: %+v", b) + } +} + +// The field reads as written and writes back the same, and refuses what says nothing. +func TestAnOffersIdentityIsParsedStrictly(t *testing.T) { + for _, raw := range []string{ + `{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`, + `{"name":"wildcard-resolution","scope":"mesh","identity":false}`, + `{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`, + } { + var o Offer + if err := json.Unmarshal([]byte(raw), &o); err != nil { + t.Fatalf("%s: %v", raw, err) + } + back, err := json.Marshal(o) + if err != nil || string(back) != raw { + t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err) + } + } + for _, raw := range []string{ + `{"name":"x","identity":true}`, + `{"name":"x","identity":{"max":20,"in":"y","most":3}}`, + } { + var o Offer + if err := json.Unmarshal([]byte(raw), &o); err == nil { + t.Errorf("accepted %s", raw) + } + } + bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{ + {Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}}, + {Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}}, + }} + raw, err := json.Marshal(bad) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") || + !strings.Contains(err.Error(), "the shortest the mesh makes") { + t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err) + } +} + +// Refused before merge: the catalogue check judges each module's identity, on the longest machine +// name, against the bound of every provision it wants — and names the module and the slug to set. +func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) { + shelf := Shelf{ + "objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}, + "photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}}, + "files": {Module: "files", Requires: []string{"s3-bucket"}}, + } + problems := IdentityProblems(shelf, 6) + if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") || + !strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) || + !strings.Contains(problems[0], "`slug` of at most 8 characters") { + t.Fatalf("one overflow, named with its remedy, was expected: %q", problems) + } + // A longer machine name refuses more: the check is about the mesh's machines, not one. + if got := IdentityProblems(shelf, 10); len(got) != 2 { + t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got) + } + // And a slug is the remedy it names. + album := shelf["photoalbum"] + album.Slug = "album" + shelf["photoalbum"] = album + if got := IdentityProblems(shelf, 6); len(got) != 0 { + t.Fatalf("a slug that fits is still refused: %q", got) + } +} + +// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a +// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's +// whole machine with it; the resolver keeps no name, so it is not refused at all. +func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) { + shelf := Shelf{ + "resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}, + "networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}}, + } + if CheckIdentity("laptop", "networkmanager") == nil { + t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound") + } + if got := IdentityProblems(shelf, 6); len(got) != 0 { + t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got) + } + r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]}, + Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager", + Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}} + if got := r.Overflowing(); len(got) != 0 { + t.Fatalf("a keyless requirement is reported as overflowing: %+v", got) + } +} + +// The consumer's side of the same judgement the provider's composition makes: what `status` says. +func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) { + bound := IdentityBound{Max: 20, In: "an S3 access key"} + r := Resolution{Node: "laptop", + Modules: []Manifest{{Module: "photoalbum"}, {Module: "files"}, {Module: "gallery", Slug: "gal"}}, + Needs: []Needed{ + {Name: "s3-bucket", From: "anchor", For: "photoalbum", Identity: bound}, + {Name: "s3-bucket", From: "anchor", For: "photoalbum", Local: "second", Identity: bound}, + {Name: "s3-bucket", From: "anchor", For: "files", Identity: bound}, + {Name: "s3-bucket", From: "anchor", For: "gallery", Identity: bound}, + {Name: "licence", From: "records", For: "photoalbum", ByRecord: true, Identity: bound}, + }} + got := r.Overflowing() + if len(got) != 1 || got[0].Module != "photoalbum" || got[0].Identity != "mesh_laptop_photoalbum" || + got[0].Provider != "anchor" { + t.Fatalf("one overflow, once, was expected: %+v", got) + } + if said := got[0].String(); !strings.Contains(said, "an S3 access key keeps 20") || + !strings.Contains(said, "slug") { + t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said) + } +} + +// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one. +func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) { + serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}} + wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh, + Identity: &OfferIdentity{Max: 255, In: "a client id"}}}} + if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") { + t.Fatalf("a 255-character bound on a DNS label passed: %q", got) + } + unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}} + if got := CheckServes(unsaid); len(got) != 0 { + t.Fatalf("the default bound (20) on a DNS label was refused: %q", got) + } +} diff --git a/go/catalogue/identity_test.go b/go/catalogue/identity_test.go new file mode 100644 index 0000000..580bc2e --- /dev/null +++ b/go/catalogue/identity_test.go @@ -0,0 +1,53 @@ +package catalogue + +import "testing" + +// Each test names the decision it defends (novox/hq ADR 0017). + +func TestASlugIsPreferredOverTheModuleName(t *testing.T) { + // A module that declared a slug is identified by it, so a long name can be made to fit the + // tightest backend without a hash (novox/hq ADR 0049). + if got := IdentitySource("kc", "keycloak"); got != "kc" { + t.Errorf("the slug was not preferred: %q", got) + } + if got := IdentitySource("", "redis"); got != "redis" { + t.Errorf("without a slug, the name should be used: %q", got) + } + if ConsumerIdentity("anchor", IdentitySource("bkt", "bucketuser")) != "mesh_anchor_bkt" { + t.Error("a slug did not shape the identity") + } +} + +func TestCheckIdentityFitsTheTightestBackend(t *testing.T) { + // 20 is an S3 access key's limit (04-ISSUES/010), and the one that binds. mesh_anchor_keycloak + // is exactly 20 and allowed; the un-slugged bucketuser is 22 and refused, with the remedy a slug. + if err := CheckIdentity("anchor", "keycloak"); err != nil { // mesh_anchor_keycloak = 20 + t.Errorf("a 20-character identity was refused: %v", err) + } + if err := CheckIdentity("anchor", "bucketuser"); err == nil { // mesh_anchor_bucketuser = 22 + t.Error("an over-long identity was accepted") + } + // But with a slug it fits, and is accepted. + if err := CheckIdentity("anchor", IdentitySource("bkt", "bucketuser")); err != nil { + t.Errorf("a slugged identity that fits was refused: %v", err) + } +} + +func TestTheRefusalNamesTheRemedy(t *testing.T) { + err := CheckIdentity("anchor", "bucketuser") + if err == nil { + t.Fatal("expected a refusal") + } + if !contains(err.Error(), "slug") { + t.Errorf("the refusal did not point at the slug as the remedy: %v", err) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/go/catalogue/installation_test.go b/go/catalogue/installation_test.go new file mode 100644 index 0000000..563ed72 --- /dev/null +++ b/go/catalogue/installation_test.go @@ -0,0 +1,93 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged; +// prose is not; the world's registries and resolvers are the world's; a declared exception is a +// reason a reader sees. +func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) { + m := Manifest{Module: "idp", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa", + "env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}, + {"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"}, + }, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}} + got := strings.Join(InstallationProblems(m), "\n") + for _, want := range []string{ + "idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME", + "idp names the public address 51.15.22.9 at resources[1].content", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} { + if strings.Contains(got, mustNot) { + t.Errorf("%q was reported and should not be:\n%s", mustNot, got) + } + } +} + +func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) { + m := Manifest{Module: "resolver", Resources: []map[string]any{ + {"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"}, + {"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"}, + {"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"}, + {"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"}, + }} + if got := InstallationProblems(m); len(got) != 0 { + t.Fatalf("the world's names were reported: %v", got) + } +} + +func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) { + // The federation's public directory in a homeserver's config: the world's, said so, and a name + // the map does not cover is still reported. + m := Manifest{Module: "homeserver", Resources: []map[string]any{ + {"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n", + NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}}, + }} + got := InstallationProblems(m) + if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") { + t.Fatalf("got %v", got) + } +} + +func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) { + bare := Manifest{Module: "site", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"}, + }} + if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") { + t.Fatalf("an image on an installation's registry was not named: %v", got) + } + excepted := Manifest{Module: "site", Resources: []map[string]any{ + {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc", + NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}}, + }} + if got := InstallationProblems(excepted); len(got) != 0 { + t.Fatalf("a declared exception was still reported: %v", got) + } +} + +func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) { + got := InstallationProblems(Manifest{Module: "mesh-one.be"}) + if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") { + t.Fatalf("got %v", got) + } +} + +func TestABuildContextOnASeatNamesNoForge(t *testing.T) { + m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{ + {Name: "server", Kind: "image", From: "Dockerfile", + Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}}, + }}} + if got := InstallationProblems(m); len(got) != 0 { + t.Fatalf("a context on a seat was reported: %v", got) + } + m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"} + if got := InstallationProblems(m); len(got) != 1 { + t.Fatalf("a context by URL was not reported: %v", got) + } +} diff --git a/go/catalogue/invokes_test.go b/go/catalogue/invokes_test.go new file mode 100644 index 0000000..7612ad2 --- /dev/null +++ b/go/catalogue/invokes_test.go @@ -0,0 +1,31 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the +// two shapes the grant has: a named tool, and every tool. +func TestAManifestMaySayWhatItInvokes(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` + + `"invokes":["mesh-catalog.catalog_modules","*"]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.Invokes) != 2 || m.Invokes[1] != "*" { + t.Fatalf("invokes not read: %v", m.Invokes) + } +} + +// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than +// at the composition of the bus's user list where it would stop the file for everybody. +func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`)) + if err == nil { + t.Fatal("an invoke naming no tool was accepted") + } + if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) { + t.Fatalf("refused for the wrong reason: %v", err) + } +} diff --git a/go/catalogue/issued_credential_test.go b/go/catalogue/issued_credential_test.go new file mode 100644 index 0000000..88b235d --- /dev/null +++ b/go/catalogue/issued_credential_test.go @@ -0,0 +1,42 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer +// names the tool that answers it and no own secret, and every other shape is refused at parse. +func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"media","version":"1", + "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], + "serves":{"media-api":{"port":32400}}}`)) + if err != nil { + t.Fatal(err) + } + if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" { + t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued) + } + if _, shared := m.SharedCredentialOf("media-api"); shared { + t.Fatal("an issued credential was read as an own secret shared") + } + if _, issued := m.IssuedCredentialOf("other"); issued { + t.Fatal("a provision the module does not offer reads as issued") + } + + for want, raw := range map[string]string{ + "the issuers an offer may name": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, + "names no tool that answers it": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, + "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, + "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, + "the issuers an offer may name ": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, + } { + want = strings.TrimSpace(want) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} diff --git a/go/catalogue/issued_outside_test.go b/go/catalogue/issued_outside_test.go new file mode 100644 index 0000000..54e5d29 --- /dev/null +++ b/go/catalogue/issued_outside_test.go @@ -0,0 +1,46 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164): +// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside +// either. An offer that says `software` keeps ADR 0305's shape. +func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"tv","version":"1", + "provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}], + "serves":{"tv-api":{"port":8989}}}`)) + if err != nil { + t.Fatal(err) + } + if !m.IssuedOutsideOf("tv-api") { + t.Fatal("the offer's credential issued outside was not read") + } + if m.IssuedOutsideOf("other") { + t.Fatal("a provision the module does not offer reads as issued outside") + } + if _, issued := m.IssuedCredentialOf("tv-api"); issued { + t.Fatal("a credential issued outside was read as issued by the software") + } + if _, shared := m.SharedCredentialOf("tv-api"); shared { + t.Fatal("a credential issued outside was read as an own secret shared") + } + if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" { + t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered) + } + + for want, raw := range map[string]string{ + "names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}}, + "provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`, + "names the tool": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`, + "the issuers an offer may name": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} diff --git a/go/catalogue/jail_problems_test.go b/go/catalogue/jail_problems_test.go new file mode 100644 index 0000000..800d36a --- /dev/null +++ b/go/catalogue/jail_problems_test.go @@ -0,0 +1,49 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A jail whose pattern names twice is refused by name: fail2ban would read it as a duplicate +// capture group and refuse its whole configuration, leaving the machine with no bans at all — which +// is what happened on the control node the day this was built (novox/hq ADR 0179). +func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) { + twice := Manifest{Module: "route-proxy", Jails: []Jail{{ + Name: "route-proxy", + Failregex: `^.*(?:refused for :\d+|asked from :\d+)$`, + Jail: "backend = systemd", + }}} + problems := twice.jailProblems() + if len(problems) != 1 || !strings.Contains(problems[0], "names 2 times") { + t.Fatalf("a pattern naming twice was not refused by name: %v", problems) + } + if !strings.Contains(problems[0], "no bans at all") { + t.Errorf("the refusal does not say what it costs: %q", problems[0]) + } + + // The same two shapes, one pattern each, is how it is written — and is accepted. + split := Manifest{Module: "route-proxy", Jails: []Jail{{ + Name: "route-proxy", + Failregex: "^.*refused for :\\d+$\n ^.*asked from :\\d+$", + Jail: "backend = systemd", + }}} + if problems := split.jailProblems(); len(problems) != 0 { + t.Fatalf("one per pattern was refused: %v", problems) + } +} + +// A jail with no name, no pattern, or a name another jail of the same module already took is refused. +func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) { + m := Manifest{Module: "mailu", Jails: []Jail{ + {Name: "", Failregex: "^.*rip=$"}, + {Name: "mailu-front", Failregex: " "}, + {Name: "mailu-front", Failregex: "^.*rip=$"}, + }} + problems := strings.Join(m.jailProblems(), "\n") + for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} { + if !strings.Contains(problems, want) { + t.Errorf("missing %q in:\n%s", want, problems) + } + } +} diff --git a/go/catalogue/jails_into_test.go b/go/catalogue/jails_into_test.go new file mode 100644 index 0000000..9586458 --- /dev/null +++ b/go/catalogue/jails_into_test.go @@ -0,0 +1,72 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A node's fail2ban jails are composed from the modules it runs (novox/hq to-be 31): the holder +// (jailing) gathers every module's declared jail into one jail file and a filter file per jail. +func TestJailsAreComposedFromTheNodesModules(t *testing.T) { + modules := []Manifest{ + {Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}}, + {Module: "postgres", Jails: []Jail{{Name: "postgres-auth", Failregex: "auth failed from ", Jail: "port = 5432\nmaxretry = 5"}}}, + } + files := jailsInto(modules, modules[0].Jailing) + + by := map[string]map[string]any{} + for _, f := range files { + by[f["id"].(string)] = f + } + jail := by[ComposedJailsID()] + if jail == nil || jail["path"] != "/etc/fail2ban/jail.d/mesh-composed.conf" { + t.Fatalf("the composed jail file was not written: %v", jail) + } + body := jail["content"].(string) + if !strings.Contains(body, "[postgres-auth]") || !strings.Contains(body, "filter = postgres-auth") || + !strings.Contains(body, "port = 5432") { + t.Fatalf("the postgres jail stanza was not composed in:\n%s", body) + } + filter := by["filter-postgres-auth"] + if filter == nil || filter["path"] != "/etc/fail2ban/filter.d/postgres-auth.conf" { + t.Fatalf("the jail's filter file was not written: %v", filter) + } + if !strings.Contains(filter["content"].(string), "failregex = auth failed from ") { + t.Fatalf("the failregex was not written: %v", filter["content"]) + } +} + +// A holder whose node runs no jail-declaring module still gets the file, empty — so removing the +// last jail is a change the service restarts on, not a file that vanishes. +func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) { + files := jailsInto([]Manifest{{Module: "fail2ban"}}, &Jailing{Into: "/x", FilterInto: "/f"}) + if len(files) != 1 || files[0]["id"] != ComposedJailsID() { + t.Fatalf("the empty composed jail file was not written alone: %v", files) + } +} + +// A changed pattern restarts fail2ban (novox/hq issue 191's rollout): the service restarts when the +// composed jail file changes, and the filter is a file of its own, so the jail file names the +// filter's digest. Changing only the failregex must change the jail file; the same pattern must not. +func TestAChangedFilterChangesTheJailFile(t *testing.T) { + jailFile := func(failregex string) string { + modules := []Manifest{ + {Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh.conf", FilterInto: "/etc/fail2ban/filter.d"}}, + {Module: "gitea", Jails: []Jail{{Name: "gitea", Failregex: failregex, Jail: "port = 222"}}}, + } + for _, f := range jailsInto(modules, modules[0].Jailing) { + if f["id"] == ComposedJailsID() { + return f["content"].(string) + } + } + t.Fatal("no composed jail file") + return "" + } + before := jailFile("web login failed from ") + if again := jailFile("web login failed from "); again != before { + t.Errorf("the same pattern composed a different jail file, which would restart fail2ban for nothing") + } + if after := jailFile("web login failed from \n Invalid user .* from "); after == before { + t.Errorf("a changed pattern left the jail file as it was, so fail2ban keeps the old filter:\n%s", after) + } +} diff --git a/go/catalogue/kept_test.go b/go/catalogue/kept_test.go new file mode 100644 index 0000000..f60f8c3 --- /dev/null +++ b/go/catalogue/kept_test.go @@ -0,0 +1,68 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A module that keeps the operator-sealed secrets is handed the export as a file, at 0600, and a +// module that does not keep them is handed nothing — the export goes to the vault and nowhere else. +func TestOnlyAModuleThatKeepsGetsTheExport(t *testing.T) { + vault := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"), + Keeps: "/var/lib/mesh-vault/root"} + other := Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")} + got, err := Resolve(shelf(vault, other), []string{"mesh-vault", "zsh"}, + Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) + if err != nil { + t.Fatal(err) + } + kept := &KeptExport{Export: 1, OperatorKey: "OPERATOR", Fingerprint: "sha256:abcd", + Kept: []Kept{{Node: "anchor", Module: "postgres", Name: "superuser", Origin: "accepted", Sealed: "CIPHERTEXT", Key: "OPERATOR"}}} + out, err := got.Declaration(Rendering{Kept: kept}) + if err != nil { + t.Fatal(err) + } + var files int + for _, r := range out { + if r["path"] != "/var/lib/mesh-vault/root/export.json" { + continue + } + files++ + if r["mode"] != "0600" { + t.Errorf("the export is written at mode %v, and it is the mesh's root secrets, sealed or not", r["mode"]) + } + content, _ := r["content"].(string) + for _, want := range []string{`"operator-key": "OPERATOR"`, `"sealed": "CIPHERTEXT"`, `"module": "postgres"`} { + if !strings.Contains(content, want) { + t.Errorf("the export lacks %s:\n%s", want, content) + } + } + if id, _ := r["id"].(string); !strings.Contains(id, "mesh-vault") { + t.Errorf("the export's resource id %q does not carry its module", id) + } + } + if files != 1 { + t.Fatalf("%d export files; one module keeps them", files) + } + + // No operator key yet: the vault is declared without the file, not with an empty one. + out, err = got.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, r := range out { + if r["path"] == "/var/lib/mesh-vault/root/export.json" { + t.Fatal("an export was written with nothing to export") + } + } +} + +func TestKeepsMustBeAnAbsolutePath(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`)) + if err == nil || !strings.Contains(err.Error(), "keeps") { + t.Fatalf("a relative keeps path was not refused: %v", err) + } + if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil { + t.Fatalf("an absolute keeps path was refused: %v", err) + } +} diff --git a/go/catalogue/kinded_test.go b/go/catalogue/kinded_test.go new file mode 100644 index 0000000..896a87f --- /dev/null +++ b/go/catalogue/kinded_test.go @@ -0,0 +1,168 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches. +func router() Manifest { + return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"}, + State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger", + DefinesSeats: []SeatDeclaration{ + {Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}, + Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}}, + {Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}}, + {Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}}, + }, + Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}}, + Uses: []string{"channel"}} +} + +func aChannel(module, kind string) Manifest { + return Manifest{Module: module, Claims: []Claim{ + {Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}} +} + +func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) { + shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "desk-channel": aChannel("desk-channel", "desktop")} + if got := problemsFor(t, shelf); got != "" { + t.Fatalf("two kinds were refused: %s", got) + } + for _, m := range shelf { + if got := declaredSeatProblems(m); len(got) > 0 { + t.Fatalf("%s: %v", m.Module, got) + } + } +} + +func TestASecondClaimOfOneKindIsRefused(t *testing.T) { + got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "telegram-two": aChannel("telegram-two", "telegram")}) + if !strings.Contains(got, `of kind "telegram", which telegram already claims`) { + t.Fatalf("a second holder of one kind stood: %s", got) + } +} + +func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { + got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")}) + if !strings.Contains(got, "claims the kinded bench channel and names no kind") { + t.Fatalf("a claim without a kind stood: %s", got) + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}} + got = problemsFor(t, Shelf{"messenger": router(), "odd": odd}) + if !strings.Contains(got, "only a kinded bench takes a kind") { + t.Fatalf("a kind on a seat that is not kinded stood: %s", got) + } + dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")}) + if !strings.Contains(dotted, "not a usable name") { + t.Fatalf("a kind that would widen a subject stood: %s", dotted) + } +} + +func TestOnlyChannelAndIntakeAreKinded(t *testing.T) { + m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") { + t.Fatalf("another kinded bench was declared: %s", got) + } +} + +func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) { + m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"}, + ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits", + "declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} { + if !strings.Contains(got, want) { + t.Errorf("not refused: %q in %s", want, got) + } + } +} + +// Two kinds on one machine are two holders, and one kind on two machines is a second claimant. +func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { + modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")} + held, problems := checkClaims(modules, Node{Name: "anchor"}, nil, nil) + if len(problems) > 0 || len(held) != 4 { + t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems) + } + _, problems = checkClaims([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil) + if len(problems) == 0 { + t.Fatal("one kind held on two machines was not refused") + } +} + +// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. +func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { + good := aChannel("telegram", "telegram") + good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + good.RunsAs = "telegram" + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { + t.Fatalf("the vocabulary was refused: %s", got) + } + bad := aChannel("telegram", "telegram") + bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} + got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad}) + for _, w := range []string{`"trusted"`, `"max-length:lots"`} { + if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { + t.Errorf("%s was not refused: %s", w, got) + } + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}} + if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { + t.Errorf("capabilities on a seat that is not kinded stood: %s", got) + } +} + +// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an +// account of its own — never carried by the machine's runtime, which runs as the operator's account. +func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { + r := router() + r.RunsAs = "" + if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { + t.Errorf("a router on the machine's runtime stood: %s", got) + } + tg := aChannel("telegram", "telegram") + tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { + t.Errorf("a verified channel on the machine's runtime stood: %s", got) + } + desk := aChannel("desk-channel", "desktop") + desk.Claims[0].Capabilities = []string{"choice"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { + t.Errorf("a channel proving nothing was held to it: %s", got) + } + // A kind that is `private` shows a link's code, which links an account as the operator: its holder is + // trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09). + private := aChannel("desk-channel", "desktop") + private.Claims[0].Capabilities = []string{"choice", "private"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") { + t.Errorf("a private channel on the machine's runtime stood: %s", got) + } +} + +func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { + ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} + if got := RunsAsProblems(ok); len(got) != 0 { + t.Fatalf("a sound runs-as was refused: %v", got) + } + for want, change := range map[string]func(*Manifest){ + "never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, + "not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" }, + "which it does not make": func(m *Manifest) { m.Resources = nil }, + "declares no own secret": func(m *Manifest) { m.OwnSecrets = nil }, + "they are the account's own": func(m *Manifest) { m.SecretsOwner = "" }, + } { + m := ok + m.Resources = append([]map[string]any(nil), ok.Resources...) + m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}} + change(&m) + if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { + t.Errorf("want %q, got %q", want, got) + } + } +} diff --git a/go/catalogue/licence_test.go b/go/catalogue/licence_test.go new file mode 100644 index 0000000..0860b62 --- /dev/null +++ b/go/catalogue/licence_test.go @@ -0,0 +1,269 @@ +package catalogue + +import ( + "strings" + "testing" +) + +func aModelUser() Manifest { + return Manifest{Module: "assistant", Requires: []string{"model-access"}, + Binds: map[string]string{"model-access": "/etc/assistant/model.json"}, + Secrets: map[string]string{"model-access": "/etc/assistant/key"}} +} + +// A provision answered by a record rather than a node. +// +// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public +// internet, so the rule that refuses two ends sharing no private network must not apply. This +// node is deliberately not on the private network at all — under the old rule that alone would +// refuse it. +func TestAProvisionAnsweredByARecordDoesNotNeedAPrivateNetwork(t *testing.T) { + got, err := Resolve( + map[string]Manifest{"assistant": aModelUser()}, + []string{"assistant"}, + Node{Name: "workstation"}, + World{ + Licences: map[string][]Record{"model-access": {{Name: "personal", + Serves: map[string]any{"model": "a-model"}}}}, + Using: map[string]map[string]Record{"assistant": {"model-access": {Name: "personal", + Serves: map[string]any{"model": "a-model"}}}}, + }) + if err != nil { + t.Fatalf("a machine off the private network could not be given model access: %v", err) + } + if len(got.Needs) != 1 { + t.Fatalf("the licence was not recorded as something this node takes: %+v", got.Needs) + } + if !got.Needs[0].ByRecord { + t.Fatal("the licence was treated as a machine, so the reachability rule would apply to it") + } + if got.Needs[0].From != "personal" { + t.Fatalf("the licence is not named by what a person calls it: %+v", got.Needs[0]) + } +} + +// Refused when the consumer has not said which — and the refusal names the candidates and the +// command, because ADR 0024 warns this will be felt: a mesh holding three ways to reach a model +// refuses every consumer that has not chosen. +func TestAConsumerThatHasNotSaidWhichLicenceIsRefusedWithTheCandidates(t *testing.T) { + _, err := Resolve( + map[string]Manifest{"assistant": aModelUser()}, + []string{"assistant"}, + Node{Name: "workstation"}, + World{Licences: map[string][]Record{"model-access": { + {Name: "personal"}, {Name: "the-organisation"}, + }}}) + if err == nil { + t.Fatal("a consumer was given model access without anybody saying which") + } + said := err.Error() + for _, want := range []string{"personal", "the-organisation", "licence use"} { + if !strings.Contains(said, want) { + t.Fatalf("the refusal does not name %q, so it is correct and unusable:\n%s", want, said) + } + } +} + +// A model the mesh runs itself answers it locally, and a record is not consulted. +func TestAModelInTheMeshsOwnSetAnswersItWithoutALicence(t *testing.T) { + got, err := Resolve( + map[string]Manifest{ + "assistant": aModelUser(), + "ollama": {Module: "ollama", + Provides: []Offer{{Name: "model-access", Scope: ScopeNode}}}, + }, + []string{"assistant", "ollama"}, + Node{Name: "workstation"}, + World{Licences: map[string][]Record{"model-access": {{Name: "personal"}}}}) + if err != nil { + t.Fatalf("a machine running its own model was asked to choose a licence: %v", err) + } + for _, n := range got.Needs { + if n.ByRecord { + t.Fatal("a record was used although the answer was on this machine") + } + } +} + +// A key that was never supplied is refused by name rather than silently not written. +// +// The mesh discarded the plaintext when the key was accepted and cannot seal another, so a +// machine that resolved cleanly would receive no file and fail at whatever read it. +func TestAModuleOnALicenceWithNoKeyIsRefusedRatherThanLeftEmpty(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{aModelUser()}, + Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant"}}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module was given a licence with no key, so it receives nothing and fails later") + } + if !strings.Contains(err.Error(), "licence key personal") { + t.Fatalf("the refusal does not say how to fix it: %v", err) + } +} + +// And with a key, both files arrive: what is public, and what is not. +func TestALicenceDeliversWhatIsPublicAndWhatIsSealed(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{aModelUser()}, + Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant", + Serves: map[string]any{"model": "a-model"}, Sealed: "sealed-blob"}}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + files := map[string]map[string]any{} + for _, res := range out { + if path, ok := res["path"].(string); ok { + files[path] = res + } + } + bound, given := files["/etc/assistant/model.json"] + if !given { + t.Fatal("the consumer was not told what it needs to know that is not secret") + } + content, _ := bound["content"].(string) + if !strings.Contains(content, "a-model") { + t.Fatalf("the binding does not carry what the licence serves:\n%s", content) + } + // The binding says it is a record rather than leaving an empty address, which a reader would + // take for something the mesh failed to fill in. + if !strings.Contains(content, "not a machine") { + t.Fatalf("the binding leaves an empty address with no explanation:\n%s", content) + } + key, delivered := files["/etc/assistant/key"] + if !delivered { + t.Fatal("the key was not delivered") + } + if key["sealed"] != "sealed-blob" { + t.Fatalf("the key is not the sealed one: %+v", key) + } + // And never in the open. The whole arrangement is that what travels is unreadable by + // everything between here and the machine. + if strings.Contains(content, "sealed-blob") { + t.Fatal("the key was written into the public file as well") + } +} + +// One machine's unresolvable module must not remove it from the private network. +// +// The pass that answers *what does this node offer* takes a failed resolution to mean it learned +// nothing — so refusing an unanswerable requirement there makes the machine disappear, and every +// other machine is then told, wrongly, that the two of them share no network. A wrong answer about +// a machine nobody asked about, caused by a fault on a third. +func TestAnUnanswerableRequirementDoesNotRemoveAMachineFromTheMesh(t *testing.T) { + shelf := map[string]Manifest{ + "assistant": aModelUser(), + "networking": {Module: "networking", + Provides: []Offer{{Name: "mesh-network", Scope: ScopeNode}}}, + } + // The first pass: what does this machine offer? It is assigned something nothing answers. + got, err := Resolve(shelf, []string{"assistant", "networking"}, + Node{Name: "laptop"}, World{Unchecked: true}) + if err != nil { + t.Fatalf("a machine with one unanswerable requirement was lost entirely: %v", err) + } + var offers bool + for _, m := range got.Modules { + for _, o := range m.Offers() { + if o == "mesh-network" { + offers = true + } + } + } + if !offers { + t.Fatal("the machine's own network module was not seen, so it looks off the network") + } + + // And the second pass, where the question is actually being asked, still refuses it. + if _, err := Resolve(shelf, []string{"assistant", "networking"}, + Node{Name: "laptop"}, World{}); err == nil { + t.Fatal("the requirement nothing answers was accepted when it was actually asked") + } +} + +// A same-node provider that mints no credential but serves a port the consumer cannot guess must +// still deliver that port. A `local-model` provider (ollama) provides `model-access` at node scope +// and serves a port and a model name, minting nothing; a co-located consumer requires and binds it +// and has a file that names `${bound:model-access:port}`. The served facts never reached the +// consumer's needs — node scope set `local`, not `brokered`, so the delivering branch was skipped — +// and the consumer's file was refused for naming a provision it "did not require". The endpoint is +// keyless, but a port is still a fact nobody can invent. +func TestAKeylessSameNodeProviderStillDeliversWhatItServes(t *testing.T) { + provider := Manifest{Module: "local-model", + Provides: []Offer{{Name: "model-access", Scope: ScopeNode}}, + Serves: map[string]map[string]any{ + "model-access": {"port": 11434, "model": "a-model"}}} + consumer := Manifest{Module: "assistant", Requires: []string{"model-access"}, + Binds: map[string]string{"model-access": "/etc/assistant/model.json"}} + + // node.At empty: a single-node deployment with no private network. The delivered binding must + // still carry a usable address — loopback, because a machine off the network still reaches + // itself, and an empty `at` would be written into the consumer's file as a host that resolves + // to nothing. + got, err := Resolve( + map[string]Manifest{"local-model": provider, "assistant": consumer}, + []string{"assistant", "local-model"}, + Node{Name: "workstation"}, + World{}) + if err != nil { + t.Fatalf("a keyless same-node model endpoint was refused: %v", err) + } + + var found *Needed + for i := range got.Needs { + if got.Needs[i].Name == "model-access" && got.Needs[i].For == "assistant" { + found = &got.Needs[i] + } + } + if found == nil { + t.Fatalf("the served endpoint was not delivered to the consumer at all: %+v", got.Needs) + } + if found.ByRecord { + t.Fatal("a same-node endpoint was treated as a record, so the reachability rule would apply") + } + if found.At == "" { + t.Fatalf("the binding carries no address, so its file names a host that resolves to nothing: %+v", found) + } + if found.At != "127.0.0.1" { + t.Fatalf("off the private network the address must fall back to loopback, got %q", found.At) + } + if got, want := plainly(found.Serves["port"]), "11434"; got != want { + t.Fatalf("the port the consumer cannot guess was not delivered: got %q want %q", got, found.Serves) + } + if found.Serves["model"] != "a-model" { + t.Fatalf("the extra served fact was not delivered: %+v", found.Serves) + } + + // End to end: a file that names ${bound:model-access:...} is filled rather than refused, which + // is the whole failure this fixes — boundInto reads knownFor, and knownFor reads the needs. + consumer.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/etc/assistant/.env", + "content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\n", + }} + got, err = Resolve( + map[string]Manifest{"local-model": provider, "assistant": consumer}, + []string{"assistant", "local-model"}, + Node{Name: "workstation"}, + World{}) + if err != nil { + t.Fatalf("resolution refused the consumer with a bound file: %v", err) + } + out, err := got.Declaration(Rendering{}) + if err != nil { + t.Fatalf("the declaration refused the consumer's bound file: %v", err) + } + var env string + for _, res := range out { + if res["path"] == "/etc/assistant/.env" { + env, _ = res["content"].(string) + } + } + if want := "http://127.0.0.1:11434/v1"; !strings.Contains(env, want) { + t.Fatalf("the bound file was not filled with the served endpoint, want %q:\n%s", want, env) + } +} diff --git a/go/catalogue/localnetwork_test.go b/go/catalogue/localnetwork_test.go new file mode 100644 index 0000000..4a0cd6f --- /dev/null +++ b/go/catalogue/localnetwork_test.go @@ -0,0 +1,195 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The home server's case (novox/hq issue 154): it sits on 192.168.1.0/24, and also on a public /24 a +// provider handed it, beside the private network's own link and a container bridge. +var homeServer = []AttachedNetwork{ + {Link: "eth0", Network: "192.168.1.10/24"}, + {Link: "eth1", Network: "203.0.113.7/24"}, + {Link: "eth0", Network: "fe80::1/64"}, + {Link: "eth0", Network: "fd12:3456:789a::5/64"}, + {Link: "wg-mesh", Network: "10.10.0.4/24"}, + {Link: "docker0", Network: "172.17.0.1/16"}, +} + +func judged(t *testing.T) []LocalNetwork { + t.Helper() + return JudgeLocalNetworks(homeServer, []string{"eth0", "eth1"}, "wg-mesh") +} + +// **A public subnet never counts** (ADR 0340 §2), and only a private one on a link facing outside does +// (§3): the tunnel's and a bridge's are this machine's own, whatever their addresses. +func TestOnlyAPrivateNetworkOnALinkFacingOutsideCounts(t *testing.T) { + got := CountedLocalNetworks(judged(t)) + want := []string{"192.168.1.0/24", "fd12:3456:789a::/64"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("counted %v, want %v", got, want) + } + for _, j := range judged(t) { + if j.Why == "" { + t.Errorf("%s on %s is judged without a why", j.Network, j.Link) + } + switch j.Network { + case "203.0.113.0/24": + if j.Counts || !strings.Contains(j.Why, "public") { + t.Errorf("the public subnet is judged %v (%s), want never counted as public", j.Counts, j.Why) + } + case "fe80::/64": + if j.Counts || !strings.Contains(j.Why, "link-local") { + t.Errorf("a link-local network is judged %v (%s)", j.Counts, j.Why) + } + case "10.10.0.0/24", "172.17.0.0/16": + if j.Counts { + t.Errorf("%s on %s counts, and it is not the local network", j.Network, j.Link) + } + } + } +} + +// A network overlapping a private range without lying wholly inside it does not count either, so a +// wide prefix around a private range cannot carry the internet with it. +func TestANetworkNotWhollyInAPrivateRangeDoesNotCount(t *testing.T) { + for _, n := range []string{"10.0.0.0/7", "172.0.0.0/8", "100.64.0.0/10", "0.0.0.0/0", "::/0"} { + got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: n}}, []string{"eth0"}, "") + if len(got) != 1 || got[0].Counts { + t.Errorf("%s counts as a local network: %+v", n, got) + } + } +} + +// **The filter admits the local network on that port's rule alone** (§5), beside the private network, +// in both chains, and never the public subnet. +func TestALocalNetworkPortIsAdmittedFromItsNetworksAndTheMesh(t *testing.T) { + rules := []Rule{ + {Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}}, + {Port: 5432, Protocol: "tcp", From: FromMesh, Because: []string{"postgres"}}, + } + local := CountedLocalNetworks(judged(t)) + out := AsNftablesWithLocal(rules, []string{"10.10.0.1", "10.10.0.4"}, false, nil, + []string{"eth0", "eth1"}, "wg-mesh", local) + + for _, want := range []string{ + "ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } tcp dport 1883 accept", + "ip6 saddr { fd12:3456:789a::/64 } tcp dport 1883 accept", + "ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } ct original proto-dst 1883 accept", + // The mesh-only port is not widened by the machine having a local network. + "ip saddr { 10.10.0.1, 10.10.0.4 } tcp dport 5432 accept", + } { + if !strings.Contains(out, want) { + t.Errorf("the rule set lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "203.0.113") { + t.Errorf("the public subnet reached the rule set:\n%s", out) + } + if strings.Contains(out, "tcp dport 1883 accept\n") && !strings.Contains(out, "saddr") { + t.Errorf("1883 is open to anywhere:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(line) == "tcp dport 1883 accept" { + t.Fatalf("1883 is open to anywhere:\n%s", out) + } + } +} + +// **Nothing resolved is closed and said** (§6): a machine that reported no private network admits the +// port from the private network only, and the rule set says it found none — never widened to anywhere. +func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) { + rules := []Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}}} + out := AsNftablesWithLocal(rules, []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "wg-mesh", nil) + if !strings.Contains(out, "1883 wanted the local network, and this machine reported no private network") { + t.Errorf("the rule set does not say the local network resolved to nothing:\n%s", out) + } + if !strings.Contains(out, "ip saddr { 10.10.0.1 } tcp dport 1883 accept") { + t.Errorf("the port is not admitted from the private network:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(line) == "tcp dport 1883 accept" { + t.Fatalf("an unresolved local network was widened to anywhere:\n%s", out) + } + } + said := LocalNetworkPorts(rules, nil)[0].Said() + if !strings.Contains(said, "none") { + t.Errorf("plan says %q, want it to say no local network was found", said) + } +} + +// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is +// still refused. +func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) { + if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + "listens":[{"port":1883,"from":"local-network"}]}`)); err != nil { + t.Fatalf("a listen from the local network is refused: %v", err) + } + _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", + "listens":[{"port":1883,"from":"lan"}]}`)) + if err == nil || !strings.Contains(err.Error(), `from "lan"`) { + t.Fatalf("a source the mesh cannot render is not refused: %v", err) + } +} + +// **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the +// filter opens — the internal name, not the public one. +func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) { + public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachLocalNetwork)) + if public != "" || internal != "app.anchor.internal" { + t.Fatalf("names are %q and %q, want the internal name only", public, internal) + } + bare := aRoutedWeb() + bare.Contributes = nil + r := Resolution{Node: "anchor", Modules: []Manifest{bare}} + rules, err := r.Rules(Rendering{Settings: reachSet(ReachLocalNetwork)}) + if err != nil { + t.Fatalf("rules: %v", err) + } + if len(rules) != 1 || rules[0].From != FromLocalNetwork { + t.Fatalf("reach local-network made the filter say %+v", rules) + } +} + +// Two modules wanting one port, one from the mesh and one from the local network, render one rule from +// the local network: it covers the mesh, and anywhere covers it. +func TestTheLocalNetworkSitsBetweenTheMeshAndAnywhere(t *testing.T) { + got := widest([]Rule{ + {Port: 53, Protocol: "udp", From: FromMesh, Because: []string{"a"}}, + {Port: 53, Protocol: "udp", From: FromLocalNetwork, Because: []string{"b"}}, + }) + if len(got) != 1 || got[0].From != FromLocalNetwork || len(got[0].Because) != 2 { + t.Fatalf("got %+v", got) + } + got = widest(append(got, Rule{Port: 53, Protocol: "udp", From: FromEverywhere, Because: []string{"c"}})) + if len(got) != 1 || got[0].From != FromEverywhere { + t.Fatalf("got %+v", got) + } +} + +// On an adopted node the opening is the private network's part only: the found firewall admits the +// local network, and an opening that widened to everywhere would open the port to the internet. +func TestAnAdoptedNodeOpensTheLocalNetworkPortToTheMeshOnly(t *testing.T) { + out := Openings([]Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork}}, nil, nil) + if len(out) != 1 || out[0]["from"] != OpeningFromMesh { + t.Fatalf("openings %v", out) + } +} + +// A global unicast IPv6 subnet on a link facing outside is public, and never counts: the IPv6 side of the +// rule that `local-network` cannot open a port to the internet (ADR 0340 §2). +func TestAGlobalIPv6SubnetIsPublicAndNeverCounts(t *testing.T) { + got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: "2001:db8:1234::7/48"}}, []string{"eth0"}, "wg-mesh") + if len(got) != 1 { + t.Fatalf("judged %+v", got) + } + if got[0].Counts { + t.Fatalf("the global IPv6 subnet %s counts as a local network", got[0].Network) + } + if got[0].Network != "2001:db8:1234::/48" || !strings.Contains(got[0].Why, "public, never counted") { + t.Fatalf("judged %s with why %q, want it named public and never counted", got[0].Network, got[0].Why) + } + if n := CountedLocalNetworks(got); len(n) != 0 { + t.Fatalf("counted %v", n) + } +} diff --git a/go/catalogue/login_shell_execute_test.go b/go/catalogue/login_shell_execute_test.go new file mode 100644 index 0000000..51b16e4 --- /dev/null +++ b/go/catalogue/login_shell_execute_test.go @@ -0,0 +1,41 @@ +package catalogue + +import "testing" + +// The login shell's `execute` runs any command as the operator account, which can become root without a +// person, so the operator withheld it on the control-node until a call to it needs a person's approval +// (novox/hq ADR 0268). It is withheld per machine by the holder's own setting, so the seat must let a +// holder hold it without serving the verb there: `execute` is optional (ADR 0246's mark), and stays so in +// a seat row read back from the store, which never keeps the mark. +func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) { + check := func(t *testing.T) { + t.Helper() + seat, ok := SeatNamed(LoginShellSeat) + if !ok { + t.Fatal("node-login-shell is not defined") + } + if len(seat.Serves) != 1 || seat.Serves[0].Name != "execute" { + t.Fatalf("the login shell promises %+v, not execute alone", seat.Serves) + } + if !seat.Serves[0].Optional { + t.Fatal("execute is required, so a holder that withholds it on one machine could not hold the seat there") + } + withholding := Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}} + if err := CanHold(withholding, seat); err != nil { + t.Fatalf("a holder serving no execute is refused: %v", err) + } + serving := withholding + serving.Claims = []Claim{{Name: LoginShellSeat, Scope: ScopeNode, Serves: []string{"execute"}}} + if err := CanHold(serving, seat); err != nil { + t.Fatalf("a holder serving execute is refused: %v", err) + } + } + t.Run("compiled", check) + t.Run("read back from the store", func(t *testing.T) { + before := seats + t.Cleanup(func() { seats = before }) + UseSeats([]Seat{{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204", + Serves: []Verb{{Name: "execute"}}}}) + check(t) + }) +} diff --git a/go/catalogue/machine_into_files_test.go b/go/catalogue/machine_into_files_test.go new file mode 100644 index 0000000..74bc60b --- /dev/null +++ b/go/catalogue/machine_into_files_test.go @@ -0,0 +1,128 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0066 — a module that must name ITSELF to something else. +// +// The authority inside the mesh is the case. A proxy reaches it at the address the mesh handed +// over, so its certificate has to be issued for that name — and it has no binding to learn the name +// from, because it provides rather than requires. Written as a literal it would be one deployment's +// machine name living in a manifest, which is the shape the decision exists to remove. + +// anchored is a machine on the private network, which is where a name worth certifying comes from. +func anchored() Node { + n := workstation() + n.At = "workstation.internal" + return n +} + +// authority is a module that must put its own machine's name into a file it writes. +func authority() Manifest { + return Manifest{ + Module: "authority", Version: "1", + Provides: FromAnywhere("acme-ca"), + Serves: map[string]map[string]any{"acme-ca": {}}, + Resources: []map[string]any{{ + "id": "init", "type": "file", "path": "/var/lib/authority/init.env", + "content": "NAMES=${machine:at},${machine:name},localhost\n", + }}, + } +} + +func TestAModuleNamesTheMachineItWasAssignedTo(t *testing.T) { + got, err := Resolve(shelf(authority()), []string{"authority"}, anchored(), World{}) + if err != nil { + t.Fatal(err) + } + + content := "" + for _, r := range mustDeclare(t, got) { + if strings.HasSuffix(plainly(r["id"]), "init") { + content = plainly(r["content"]) + } + } + if content == "" { + t.Fatal("the authority's file was not declared") + } + if strings.Contains(content, "${machine:") { + t.Fatalf("the placeholder was written into the file as a value: %q", content) + } + // The machine's own name on the private network — the one a consumer is handed as `at`, and so + // the one a certificate has to carry. + if !strings.Contains(content, "workstation.internal") { + t.Errorf("the file does not name the address consumers reach it at: %q", content) + } +} + +// A machine not on the private network has no such address, and a module asking to be reached at +// one is named here rather than left to fail later as a certificate nobody can verify. +func TestAnAddressAMachineDoesNotHaveIsRefused(t *testing.T) { + got, err := Resolve(shelf(authority()), []string{"authority"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if _, err := got.Declaration(Rendering{}); err == nil { + t.Fatal("an address the machine does not have was substituted rather than refused") + } else if !strings.Contains(err.Error(), "at") { + t.Errorf("the refusal does not name what was asked for: %v", err) + } +} + +// A module that must give software the machine's ADDRESS rather than its name — a resolver pointing +// the container runtime at itself, whose list of resolvers cannot be a name — says +// ${machine:address}, and gets what the machine's name resolves to on the private network: the same +// address every other machine's hosts file carries for it. +func TestAModuleNamesTheAddressBehindItsMachinesName(t *testing.T) { + pointing := Manifest{Module: "pointing", Version: "1", Resources: []map[string]any{{ + "id": "runtime", "type": "file", "path": "/etc/runtime.json", + "content": `{"dns":["${machine:address}"]}`, + }}} + got, err := Resolve(shelf(pointing), []string{"pointing"}, anchored(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: map[string]string{ + "workstation.internal": "10.42.0.7", "anchor.internal": "10.42.0.1"}}) + if err != nil { + t.Fatal(err) + } + if content := plainly(out[0]["content"]); content != `{"dns":["10.42.0.7"]}` { + t.Fatalf("the machine's address was not the one its name resolves to: %q", content) + } + + // Off the network there is no such address, and the refusal says what the machine does have — + // rather than a placeholder written into the runtime's file and read as an address. + got, err = Resolve(shelf(pointing), []string{"pointing"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if _, err := got.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "${machine:address}") { + t.Fatalf("a machine off the network was given an address, or refused for another reason: %v", err) + } +} + +// The mesh's private range is offered as ${machine:mesh-range}, so a module names it rather than +// hardcoding a value it cannot know (novox/hq ADR 0112) — the fail2ban ignoreip is the case. +func TestAModuleNamesTheMeshRange(t *testing.T) { + facts := machineFacts(Resolution{Node: "anchor", At: "anchor.internal"}, + map[string]string{"anchor.internal": "10.10.0.1"}, "10.10.0.0/24") + if facts["mesh-range"] != "10.10.0.0/24" { + t.Fatalf("the mesh range is not a machine fact: %v", facts) + } + res := map[string]any{"type": "file", "id": "jail", "content": "ignoreip = 127.0.0.1/8 ${machine:mesh-range}\n"} + if err := machineInto(res, facts, "fail2ban"); err != nil { + t.Fatal(err) + } + if got := res["content"].(string); !strings.Contains(got, "10.10.0.0/24") || strings.Contains(got, "${machine:") { + t.Fatalf("the mesh range was not written in: %q", got) + } + // A mesh with no range gives no such fact, and a file that names it is refused rather than + // left with a literal placeholder in it. + none := machineFacts(Resolution{Node: "anchor"}, nil, "") + if _, has := none["mesh-range"]; has { + t.Fatal("a mesh with no range still offered one") + } +} diff --git a/go/catalogue/machineside_test.go b/go/catalogue/machineside_test.go new file mode 100644 index 0000000..553e1a5 --- /dev/null +++ b/go/catalogue/machineside_test.go @@ -0,0 +1,60 @@ +package catalogue + +import "testing" + +// A module with nothing that publishes binds what it binds, and the mesh may not move it. +// +// This is the case that made novox/hq 04-ISSUES/028's fix wrong on its first pass: a port was +// assigned to every module that declared one, so a service listening directly had the rule set +// opened on a number nothing was listening on, and its real port shut. The firewall reported +// success and blocked the service, which is the exact failure the mechanism exists to prevent. +func TestAPortNothingPublishesIsNotTheMeshsToMove(t *testing.T) { + m := Manifest{Module: "talker", Listens: []Listening{{Port: 9101, From: FromMesh}}} + at, mayAssign := m.MachineSide(9101) + if mayAssign { + t.Fatal("the mesh took a port it cannot move: nothing translates it, so assigning one " + + "opens the wrong number and leaves the service unreachable") + } + if at != 9101 { + t.Fatalf("a port nothing publishes reaches the machine where it binds, not at %d", at) + } +} + +// A container publishing in short form is exactly the case the mesh may choose. +func TestAContainerPublishingShortIsTheMeshsToChoose(t *testing.T) { + m := Manifest{Module: "store", Resources: []map[string]any{ + {"type": "container", "id": "server", "ports": []any{"5432"}}, + }} + if _, mayAssign := m.MachineSide(5432); !mayAssign { + t.Fatal("a container's mapping is what translates a port, so this one is the mesh's to " + + "choose; refusing it puts every module back on a number it guessed") + } +} + +// A manifest that wrote its own mapping already chose, and the machine side is the outer one. +func TestAMappingTheManifestWroteIsNotReassigned(t *testing.T) { + m := Manifest{Module: "mail", Resources: []map[string]any{ + {"type": "container", "id": "front", "ports": []any{"7080:80"}}, + }} + for _, named := range []int{7080, 80} { + at, mayAssign := m.MachineSide(named) + if mayAssign { + t.Fatalf("%d was reassigned though the manifest published it explicitly, which "+ + "would open a rule on a port the container does not publish", named) + } + if at != 7080 { + t.Fatalf("naming %d gave %d; the machine side of 7080:80 is 7080", named, at) + } + } +} + +// A port some other container publishes is not this port. +func TestAPortNotInTheMappingIsNotFound(t *testing.T) { + m := Manifest{Module: "mail", Resources: []map[string]any{ + {"type": "container", "id": "front", "ports": []any{"25", "7080:80"}}, + }} + if at, mayAssign := m.MachineSide(993); mayAssign || at != 993 { + t.Fatalf("993 is published by nothing here, so it binds where it binds: got %d, %v", + at, mayAssign) + } +} diff --git a/go/catalogue/names_test.go b/go/catalogue/names_test.go new file mode 100644 index 0000000..846d9e9 --- /dev/null +++ b/go/catalogue/names_test.go @@ -0,0 +1,100 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +func containersOf(t *testing.T, r Resolution, with Rendering) []map[string]any { + t.Helper() + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + var found []map[string]any + for _, res := range out { + if res["type"] == "container" { + found = append(found, res) + } + } + return found +} + +func namesOf(r map[string]any) []string { + var out []string + if given, ok := r["hosts"].([]any); ok { + for _, h := range given { + out = append(out, h.(string)) + } + } + return out +} + +// No mesh name is written into a container (novox/hq ADR 0148). It resolves them through its +// machine's resolver at the moment it asks, so a name that moves is answered differently by the +// next lookup, in every container, with nothing recreated. +// +// Checked the way the record says: the declaration a container gets does not move when the mesh's +// roster does. A roster with one machine and a roster with three produce the same container, byte +// for byte, so the digest a host computes from it cannot move either — which is what stopped one +// name moving from replacing every container in the mesh (issue 151). +func TestAContainerIsTheSameWhateverTheMeshsRosterSays(t *testing.T) { + module := Manifest{Module: "app", Resources: []map[string]any{{"id": "web", "type": "container", + "name": "web", "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}} + one := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}}, + Rendering{Names: map[string]string{"laptop.internal": "10.42.0.2"}}) + three := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}}, + Rendering{Names: map[string]string{ + "anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "git.example.tld": "10.42.0.1", + }}) + if len(one) != 1 || len(three) != 1 { + t.Fatalf("expected one container each, got %d and %d", len(one), len(three)) + } + if given := namesOf(three[0]); len(given) != 0 { + t.Fatalf("the mesh's names were copied into the container: %v", given) + } + if !reflect.DeepEqual(one[0], three[0]) { + t.Fatalf("the container moved with the roster:\n%v\n%v", one[0], three[0]) + } +} + +// The names a module declares for itself are its own: part of what the module is, kept exactly as +// written, and the mesh does not know what they mean. They are the one thing in a container's +// hosts that does move its identity, because they do not move when the mesh's roster does. +func TestAContainersOwnNamesAreKeptAsWritten(t *testing.T) { + got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{ + Module: "app", + Resources: []map[string]any{{"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64), + "hosts": []any{"something.else:203.0.113.9"}}}, + }}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}}) + + given := namesOf(got[0]) + if len(given) != 1 || given[0] != "something.else:203.0.113.9" { + t.Fatalf("the container's own names were not kept as written: %v", given) + } +} + +// No resource is given a `hosts` key it did not declare. A file or a service carrying one is a +// declaration the host refuses outright — it takes no unknown field — so an invented key breaks +// the whole machine rather than one resource. +func TestNothingIsGivenNamesItDidNotDeclare(t *testing.T) { + out, err := Resolution{Node: "laptop", Modules: []Manifest{{ + Module: "app", + Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/app.conf", "content": "x", "mode": "0644"}, + {"id": "run", "type": "service", "unit": "app.service", "state": "running"}, + {"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}, + }, + }}}.Declaration(Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}}) + if err != nil { + t.Fatal(err) + } + for _, r := range out { + if _, given := r["hosts"]; given { + t.Fatalf("a %v was given names it never declared: %v", r["type"], r) + } + } +} diff --git a/go/catalogue/naming_test.go b/go/catalogue/naming_test.go new file mode 100644 index 0000000..c168ea4 --- /dev/null +++ b/go/catalogue/naming_test.go @@ -0,0 +1,75 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provision names what the consumer is coupled to (novox/hq ADR 0027). +// +// The fault this defends against does not look like a fault: resolution succeeds, the module +// deploys, and the first query fails somewhere else entirely. Every earlier test had exactly one +// provider of each name, so no mismatch was expressible and none was caught. +func TestAModuleMayNotProvideAGenericDatabase(t *testing.T) { + for _, hidden := range []string{"database", "db", "sql", "sql-database"} { + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", + "provides":[{"name":"` + hidden + `","scope":"mesh"}]}`)) + if err == nil { + t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden) + } + for _, want := range []string{hidden, "postgres-database", "first query"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusing %q did not mention %q, so nobody learns what to write:\n%v", + hidden, want, err) + } + } + } +} + +// The rule is about coupling, not about specificity everywhere. Naming `route` after a particular +// proxy would be the same error in the other direction. +func TestARoleNameIsFineWhereTheConsumerCannotTellTheDifference(t *testing.T) { + for _, role := range []string{"route", "resolver", "artifact-store", "postgres-database"} { + if _, err := ParseManifest([]byte(`{"module":"m","version":"1", + "provides":[{"name":"` + role + `","scope":"mesh"}]}`)); err != nil { + t.Errorf("providing %q was refused, and it names what a consumer actually gets: %v", + role, err) + } + } +} + +// A module may not declare an action, and it is refused where it was written. +// +// The host refuses one arriving over the link, which is the bound the whole security argument +// rests on (novox/hq ADR 0005) and is enforced in the right place. But a module's resources reach +// a machine *over* the link, so a manifest carrying an action used to be accepted here, stored, +// resolved and pushed — and then refused on the machine, in a log, with nothing tying it back to +// the manifest. Enforced at the far end only is enforced and not usable. +func TestAModuleMayNotDeclareAnAction(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ + {"id":"migrate","type":"action","command":["/bin/true"],"verify":["/bin/true"]}]}`)) + if err == nil { + t.Fatal("a manifest declaring an action was accepted, and the machine would refuse it") + } + if !strings.Contains(err.Error(), "may not") || !strings.Contains(err.Error(), "0005") { + t.Errorf("the refusal does not say what rule it is: %v", err) + } + // And it says what to do instead, because "you may not" without an alternative is where a + // module author stops. + if !strings.Contains(err.Error(), "ships a program") { + t.Errorf("the refusal names no alternative: %v", err) + } +} + +// Every other shape a module may declare still passes, so the check above bounds one thing. +func TestTheOtherShapesAreStillAccepted(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ + {"id":"d","type":"directory","path":"/var/lib/probe","mode":"0700"}, + {"id":"f","type":"file","path":"/var/lib/probe/x","content":"x\n"}, + {"id":"n","type":"network","name":"probe"}, + {"id":"c","type":"container","name":"probe","image":"probe@sha256:` + + `0000000000000000000000000000000000000000000000000000000000000000"}]}`)) + if err != nil { + t.Fatalf("an ordinary manifest was refused: %v", err) + } +} diff --git a/go/catalogue/needs_test.go b/go/catalogue/needs_test.go new file mode 100644 index 0000000..8a176a1 --- /dev/null +++ b/go/catalogue/needs_test.go @@ -0,0 +1,124 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A secret a module needs in order to be itself. +// +// A database has a superuser password, a broker an administrator, a registry an account. None is +// *for* anybody — it is not the credential a consumer is given, and the mechanism that hands +// those out has a consumer in the middle of it. + +func needy() Manifest { + return Manifest{ + Module: "postgres", Version: "1", + OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/mesh/postgres/superuser"}}, + Resources: []map[string]any{ + {"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"}, + }, + } +} + +func TestAModulesOwnSecretLandsSealed(t *testing.T) { + got, err := Resolve(shelf(needy()), []string{"postgres"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{ + Needed: map[string]map[string]string{"postgres": {"superuser": "c2VhbGVk"}}, + }) + if err != nil { + t.Fatal(err) + } + for _, r := range out { + if r["path"] != "/var/lib/mesh/postgres/superuser" { + continue + } + if r["sealed"] != "c2VhbGVk" { + t.Fatalf("got %v", r) + } + if r["content"] != nil { + t.Fatal("a module's own secret was written in the clear") + } + return + } + t.Fatalf("no secret was written: %v", out) +} + +func TestADeclaredNeedThatWasNotMadeIsRefused(t *testing.T) { + // Skipping it would start a database with no password it knows, which fails to authenticate + // three layers from the machine reporting it. + got, _ := Resolve(shelf(needy()), []string{"postgres"}, reachable(), World{}) + _, err := got.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module needing a secret was declared without one") + } + if !strings.Contains(err.Error(), "superuser") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestANeedIsAnAbsolutePath(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", + "own-secrets":{"superuser":"superuser.txt"}}`)) + if err == nil { + t.Fatal("a relative path was accepted") + } + if !strings.Contains(err.Error(), "absolute path") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestAModuleMayNeedSeveralThings(t *testing.T) { + // A password and a token, say. Telling them apart is the module's business, not the mesh's. + m := needy() + m.OwnSecrets["replication"] = OwnSecret{Path: "/var/lib/mesh/postgres/replication"} + got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{}) + out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{ + "postgres": {"superuser": "b25l", "replication": "dHdv"}, + }}) + if err != nil { + t.Fatal(err) + } + seen := map[string]string{} + for _, r := range out { + if path, ok := r["path"].(string); ok && strings.Contains(path, "/var/lib/mesh/postgres/") { + seen[path], _ = r["sealed"].(string) + } + } + if len(seen) != 2 || seen["/var/lib/mesh/postgres/superuser"] == seen["/var/lib/mesh/postgres/replication"] { + t.Fatalf("two needs did not land as two secrets: %v", seen) + } +} + +// A manifest written against the old name is told what the field became. +// +// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so +// reaching for the wrong one parsed cleanly and failed somewhere else entirely. Refusing the old +// name with "unknown field" would be correct and unhelpful: whoever wrote it knew what they meant, +// and the mesh knows what it is called now. +func TestAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) { + _, err := ParseManifest([]byte( + `{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`)) + if err == nil { + t.Fatal("a manifest using the old name was accepted, so two fields now mean one thing") + } + for _, want := range []string{"needs", "own-secrets"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not mention %q: %v", want, err) + } + } +} + +// And an ordinary unknown key is still refused as one, rather than being guessed at. +func TestAnInventedKeyIsNotTreatedAsARename(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`)) + if err == nil { + t.Fatal("an invented key was accepted") + } + if strings.Contains(err.Error(), "is now called") { + t.Fatalf("an invented key was reported as a rename: %v", err) + } +} diff --git a/go/catalogue/operators_machine_test.go b/go/catalogue/operators_machine_test.go new file mode 100644 index 0000000..9dfa7b3 --- /dev/null +++ b/go/catalogue/operators_machine_test.go @@ -0,0 +1,191 @@ +package catalogue + +import ( + "slices" + "strings" + "testing" +) + +// A `user` shape and a user-scoped unit name the operator account the way a home file does +// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned. +func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) { + facts := map[string]string{"account": "ops", "account-home": "/home/ops"} + login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"} + if err := machineInto(login, facts, "zsh"); err != nil { + t.Fatal(err) + } + if login["name"] != "ops" { + t.Fatalf("the user shape did not learn the account: %v", login["name"]) + } + watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service", + "scope": "user", "user": "${machine:account}"} + if err := machineInto(watcher, facts, "i3"); err != nil { + t.Fatal(err) + } + if watcher["user"] != "ops" { + t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"]) + } + // A machine with no operator account refuses rather than writing the literal. + err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"}, + map[string]string{"address": "10.0.0.1"}, "zsh") + if err == nil || !strings.Contains(err.Error(), "${machine:account}") { + t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err) + } +} + +// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq +// ADR 0177): every verb described, with a schema, taking a scope. +func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) { + seat, ok := SeatNamed("node-service-manager") + if !ok { + t.Fatal("node-service-manager is not a seat the mesh defines") + } + if seat.Scope != ScopeNode { + t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope) + } + want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed", "reset-failed", "wanted-by", "unlink-dangling"} + var got []string + for _, v := range seat.Serves { + got = append(got, v.Name) + if v.Description == "" || v.Input == nil { + t.Fatalf("%s is promised without a description or a schema", v.Name) + } + props, _ := v.Input["properties"].(map[string]any) + if _, has := props["scope"]; !has { + t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name) + } + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("the seat serves %v, not %v", got, want) + } +} + +// **`failed` joins the seat optional** (the operator's direction 2026-10-07): the holder running today, +// which does not serve it, still holds the seat; the holder that serves it is not refused; and a verb +// the seat does not promise is still refused, and one it requires is still required. +func TestFailedIsAnOptionalVerbOfTheServiceManager(t *testing.T) { + seat, _ := SeatNamed(ServiceManagerSeat) + holder := func(serves ...string) Manifest { + return Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, Serves: serves}}} + } + eight := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", + "reset-failed", "wanted-by", "unlink-dangling"} + if err := CanHold(holder(eight...), seat); err != nil { + t.Fatalf("a holder without failed is refused: %v", err) + } + if err := CanHold(holder(append(eight, "failed")...), seat); err != nil { + t.Fatalf("a holder serving failed is refused: %v", err) + } + if err := CanHold(holder(append(eight, "fail")...), seat); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Fatalf("a verb the seat does not promise was accepted: %v", err) + } + if err := CanHold(holder(eight[1:]...), seat); err == nil || !strings.Contains(err.Error(), "does not serve units") { + t.Fatalf("a holder missing a required verb was accepted: %v", err) + } + optional := map[string]bool{"failed": true} + for _, v := range seat.Serves { + if v.Optional != optional[v.Name] { + t.Errorf("%s optional: %v", v.Name, v.Optional) + } + } +} + +// **`reset-failed`, `wanted-by` and `unlink-dangling` are required** (novox/hq issue 332, ADR 0246 step 3): +// every holder serves them, so a holder serving only the nine verbs before them is refused by the verb it +// lacks; a holder serving all twelve holds the seat; each replaces the shell command it names; and read +// back from the store's row they stay required. +func TestResetFailedWantedByAndUnlinkDanglingAreRequiredVerbsOfTheServiceManager(t *testing.T) { + defer UseSeats(DefaultSeats()) + nine := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed"} + all := append(slices.Clone(nine), "reset-failed", "wanted-by", "unlink-dangling") + holder := func(serves ...string) Manifest { + return Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, Serves: serves}}} + } + refusedWithout := func(seat Seat) { + t.Helper() + for _, verb := range []string{"reset-failed", "wanted-by", "unlink-dangling"} { + serves := slices.DeleteFunc(slices.Clone(all), func(v string) bool { return v == verb }) + if err := CanHold(holder(serves...), seat); err == nil || !strings.Contains(err.Error(), "does not serve "+verb) { + t.Errorf("a holder not serving %s holds the seat: %v", verb, err) + } + } + } + seat, _ := SeatNamed(ServiceManagerSeat) + if err := CanHold(holder(nine...), seat); err == nil { + t.Fatal("a holder serving only the nine verbs before issue 332 holds the seat") + } + refusedWithout(seat) + if err := CanHold(holder(all...), seat); err != nil { + t.Fatalf("a holder serving all twelve is refused: %v", err) + } + if err := CanHold(holder(append(slices.Clone(all), "why-started")...), seat); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Fatalf("a verb the seat does not promise was accepted: %v", err) + } + replaces := map[string]string{"reset-failed": "systemctl reset-failed", "wanted-by": "systemctl list-dependencies --reverse", + "unlink-dangling": "rm ~/.config/systemd/user/*.wants/"} + for _, v := range seat.Serves { + want, ok := replaces[v.Name] + if !ok { + continue + } + if v.Optional { + t.Errorf("%s is still optional", v.Name) + } + if !slices.Contains(v.Replaces, want) { + t.Errorf("%s does not say it replaces %q: %v", v.Name, want, v.Replaces) + } + props, _ := v.Input["properties"].(map[string]any) + if _, has := props["unit"]; !has { + t.Errorf("%s takes no unit", v.Name) + } + if v.Name == "unlink-dangling" { + // Removing is the person's act: their reason is required, and kept with the record. + if required, _ := v.Input["required"].([]string); !slices.Contains(required, "why") { + t.Errorf("unlink-dangling does not require why: %v", v.Input["required"]) + } + } + delete(replaces, v.Name) + } + if len(replaces) > 0 { + t.Fatalf("the seat does not promise %v", replaces) + } + // The row as seeding stores it: every verb, the mark not a column. + var rows []Seat + for _, s := range DefaultSeats() { + row := s + row.Serves = nil + for _, v := range s.Serves { + row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input}) + } + rows = append(rows, row) + } + UseSeats(rows) + seat, _ = SeatNamed(ServiceManagerSeat) + refusedWithout(seat) + if err := CanHold(holder(all...), seat); err != nil { + t.Fatalf("read back from the row, a holder serving all twelve is refused: %v", err) + } +} + +// The optional mark is not stored, so a seat set read back from the store's rows takes it from the +// compiled seat: otherwise `failed`, seeded into the row, would come back required. +func TestAnOptionalVerbStaysOptionalInASetReadFromTheStore(t *testing.T) { + defer UseSeats(DefaultSeats()) + var rows []Seat + for _, s := range DefaultSeats() { + row := s + row.Serves = nil + for _, v := range s.Serves { + row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input}) + } + rows = append(rows, row) + } + UseSeats(rows) + seat, _ := SeatNamed(ServiceManagerSeat) + holder := Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, + Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", + "reset-failed", "wanted-by", "unlink-dangling"}}}} + if err := CanHold(holder, seat); err != nil { + t.Fatalf("a set read from rows refuses a holder without the optional failed: %v", err) + } +} diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go new file mode 100644 index 0000000..0b6f808 --- /dev/null +++ b/go/catalogue/optional_requirements_test.go @@ -0,0 +1,301 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A requirement a module can run without (novox/hq ADR 0330, issue 166). The shape of the issue: +// grafana runs without a data source and requires `influxdb-api` softly; a mesh without influxdb +// could not assign grafana at all, and one with influxdb in its catalogue would have had it installed +// for grafana. + +func optionalShelf() map[string]Manifest { + grafana := Manifest{Module: "grafana", Version: "1", Requires: []string{"influxdb-api", "shell"}, + OptionalRequires: map[string]bool{"influxdb-api": true, "shell": true}} + return shelf( + Manifest{Module: "influxdb", Version: "1", Provides: FromAnywhere("influxdb-api")}, + Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")}, + grafana, + // What wants the same names hard, for the cases where one consumer's optional is another's hard. + Manifest{Module: "telegraf", Version: "1", Requires: []string{"influxdb-api"}}, + Manifest{Module: "editor", Version: "1", Requires: []string{"shell"}}, + Manifest{Module: "suite", Version: "1", Requires: []string{"editor"}}, + ) +} + +func TestARequirementIsHardUnlessMarkedOptional(t *testing.T) { + m := mustParse(t, `{"module":"grafana","version":"1", + "requires":["route",{"name":"influxdb-api","optional":true},{"name":"oidc-client"}]}`) + if strings.Join(m.Requires, " ") != "route influxdb-api oidc-client" { + t.Fatalf("the requirements read as %v", m.Requires) + } + if m.IsOptional("route") || m.IsOptional("oidc-client") { + t.Error("a requirement without `optional` read as optional; hard is the default (ADR 0330 §1)") + } + if !m.IsOptional("influxdb-api") { + t.Error("a requirement marked optional read as hard") + } + // And written back as it was read, so a manifest through the store keeps its form. + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + var again Manifest + if err := json.Unmarshal(raw, &again); err != nil { + t.Fatal(err) + } + if !again.IsOptional("influxdb-api") || again.IsOptional("route") || len(again.Requires) != 3 { + t.Fatalf("written and read back, it says %v optional %v (%s)", again.Requires, again.OptionalRequires, raw) + } + if strings.Contains(string(raw), `"name":`) { + t.Errorf("a requirement was stored in the long form: %s", raw) + } +} + +// The stored form reads on a controller older than optional requirements (review of mesh-controller +// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside +// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's +// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside. +func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) { + m := mustParse(t, `{"module":"grafana","version":"1", + "requires":["route",{"name":"influxdb-api","optional":true}]}`) + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + // main's field, exactly: `Requires []string`. + var older struct { + Requires []string `json:"requires"` + } + if err := json.Unmarshal(raw, &older); err != nil { + t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw) + } + if strings.Join(older.Requires, " ") != "route influxdb-api" { + t.Fatalf("it reads the requirements as %v", older.Requires) + } + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + t.Fatal(err) + } + if string(keys[OptionalRequiresKey]) != `["influxdb-api"]` { + t.Fatalf("which are optional is stored as %s", keys[OptionalRequiresKey]) + } + // And this controller reads the stored form back the same, leniently and strictly. + var back Manifest + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + strict, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + for _, got := range []Manifest{back, strict} { + if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 { + t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires) + } + } + // A stored list naming something the module does not require is refused. + if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil { + t.Error("an optional requirement the module does not require passed the check") + } +} + +func TestARequirementsLongFormRefusesWhatItDoesNotKnow(t *testing.T) { + for _, raw := range []string{ + `{"module":"x","version":"1","requires":[{"name":"influxdb-api","optinal":true}]}`, + `{"module":"x","version":"1","requires":[{"optional":true}]}`, + } { + if _, err := ParseManifest([]byte(raw)); err == nil { + t.Errorf("accepted %s", raw) + } + } +} + +func TestAnOptionalRequirementWithNoProviderIsAbsentAndNothingIsInstalled(t *testing.T) { + // Exactly one catalogue module could answer each — the case where a hard requirement installs it. + // Brokered (influxdb-api, from anywhere) and on this machine (shell); both passes. + for _, world := range []World{{}, {Unchecked: true}} { + got, err := Resolve(optionalShelf(), []string{"grafana"}, reachable(), world) + if err != nil { + t.Fatalf("an optional requirement with no provider refused the assignment: %v", err) + } + if have := strings.Join(names(got), " "); have != "grafana" { + t.Fatalf("assigning grafana put %s on the machine; nothing is installed for an optional requirement (ADR 0330 §3)", have) + } + if world.Unchecked { + continue + } + want := []Absent{{Consumer: "grafana", Requirement: "influxdb-api"}, {Consumer: "grafana", Requirement: "shell"}} + if len(got.Absent) != 2 || got.Absent[0] != want[0] || got.Absent[1] != want[1] { + t.Fatalf("absent: %+v, want %+v", got.Absent, want) + } + if len(got.Needs) != 0 { + t.Fatalf("an absent requirement was given a binding: %+v", got.Needs) + } + } +} + +func TestAnOptionalRequirementWithAProviderBindsAsAHardOne(t *testing.T) { + world := World{Offered: map[string][]Provider{"influxdb-api": { + {Node: "anchor", At: "anchor.internal", Module: "influxdb", Serves: map[string]any{"port": 8086}}}}} + got, err := Resolve(optionalShelf(), []string{"grafana", "zsh"}, reachable(), world) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "grafana", "influxdb-api"); n.From != "anchor" || n.Module != "influxdb" { + t.Fatalf("bound to %s/%s", n.From, n.Module) + } + if len(got.Absent) != 0 { + t.Fatalf("a requirement with a provider was left absent: %+v", got.Absent) + } + // And the same assignment without the provider is absent: what binds it later is only the + // provider being assigned, with no act on grafana (ADR 0330 §7). + before, err := Resolve(optionalShelf(), []string{"grafana", "zsh"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + if len(before.Absent) != 1 || before.Absent[0].Requirement != "influxdb-api" { + t.Fatalf("absent before the provider was assigned: %+v", before.Absent) + } +} + +func TestOneConsumersOptionalRequirementIsAnothersHardOne(t *testing.T) { + // telegraf wants influxdb-api hard: with nothing offering it, the assignment is refused as + // before, naming what to assign — grafana's optional does not soften telegraf's. + _, err := Resolve(optionalShelf(), []string{"grafana", "telegraf"}, reachable(), World{}) + if err == nil || !strings.Contains(err.Error(), "assign influxdb") { + t.Fatalf("a hard requirement with no provider was not refused naming the candidate: %v", err) + } + // And the refusal names telegraf, whose remedy it is, not grafana, which met the name first. + if !strings.Contains(err.Error(), "wanted by telegraf") { + t.Errorf("the refusal does not name the module that wants it hard: %v", err) + } + // And a module pulled in later (suite → editor) that wants the shell hard installs it, even + // though grafana, met first, wanted it only optionally — and grafana is then bound to it. + got, err := Resolve(optionalShelf(), []string{"grafana", "suite"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") { + t.Fatalf("a hard requirement met after an optional one was not installed: %s", have) + } + for _, a := range got.Absent { + if a.Requirement == "shell" { + t.Fatalf("the shell is installed and still listed absent: %+v", got.Absent) + } + } +} + +func TestAHardRequirementWithNoProviderIsStillRefused(t *testing.T) { + _, err := Resolve(optionalShelf(), []string{"telegraf"}, reachable(), World{}) + if err == nil || !strings.Contains(err.Error(), "influxdb") { + t.Fatalf("got %v", err) + } +} + +func optionalModule(resources string) string { + return `{"module":"grafana","version":"1", + "requires":["route",{"name":"influxdb-api","optional":true}], + "secrets":{"influxdb-api":"/var/lib/grafana/influxdb-api"}, + "resources":[` + resources + `]}` +} + +func TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) { + refused := map[string]string{ + "a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", + "content":"url: ${bound:influxdb-api:at}"}`, + "a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400", + "content":"${secret:influxdb-api}"}`, + "only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", + "content":"x","only-when-bound":"route"}`, + "only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644", + "content":"x","only-when-bound":"mqtt-topic"}`, + "only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700", + "only-when-bound":"influxdb-api"}`, + "a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", + "content":"${bound:route:name|default:x}"}`, + } + for what, resource := range refused { + if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil { + t.Errorf("%s passed the module check", what) + } else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") { + t.Errorf("the refusal does not say how to write it: %v", err) + } + } + accepted := map[string]string{ + "a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", + "content":"url: ${bound:influxdb-api:at|default:none}"}`, + "a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a", + "mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}", + "only-when-bound":"influxdb-api"}`, + } + for what, resource := range accepted { + if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil { + t.Errorf("%s was refused: %v", what, err) + } + } +} + +func absentGrafana(t *testing.T, absent bool) Resolution { + t.Helper() + m := mustParse(t, optionalModule(` + {"id":"env","type":"file","path":"/var/lib/grafana/env","mode":"0644", + "content":"INFLUX=${bound:influxdb-api:at|default:none}:${bound:influxdb-api:port|default:0}\n"}, + {"id":"ds","type":"file","path":"/var/lib/grafana/ds.yaml","mode":"0400", + "content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}\n","only-when-bound":"influxdb-api"}`)) + m.Requires = []string{"influxdb-api"} + m.Binds = map[string]string{"influxdb-api": "/var/lib/grafana/influxdb.json"} + r := Resolution{Node: "workstation", Modules: []Manifest{m}} + if absent { + r.Absent = []Absent{{Consumer: "grafana", Requirement: "influxdb-api"}} + } else { + r.Needs = []Needed{{Name: "influxdb-api", From: "anchor", At: "anchor.internal", For: "grafana", + Module: "influxdb", Sealed: "sealed-influx", Serves: map[string]any{"port": float64(8086)}}} + } + return r +} + +func TestAnAbsentRequirementIsComposedWithItsDefaultsAndWithoutItsFiles(t *testing.T) { + out, err := absentGrafana(t, true).Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + env := fileNamed(out, "grafana.env") + if env == nil || env["content"] != "INFLUX=none:0\n" { + t.Fatalf("the defaults were not rendered: %v", env) + } + for _, id := range []string{"grafana.ds", "grafana." + SecretID("influxdb-api"), "grafana." + BoundID("influxdb-api")} { + if fileNamed(out, id) != nil { + t.Errorf("%s was delivered while influxdb-api is absent (ADR 0330 §5, §6)", id) + } + } + for _, r := range out { + if _, has := r[OnlyWhenBound]; has { + t.Errorf("%v carries %s to the node-engine, which knows no such field", r["id"], OnlyWhenBound) + } + } +} + +func TestABoundOptionalRequirementIsComposedAsAHardOne(t *testing.T) { + out, err := absentGrafana(t, false).Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + if env := fileNamed(out, "grafana.env"); env == nil || env["content"] != "INFLUX=anchor.internal:8086\n" { + t.Fatalf("the bound values were not rendered: %v", env) + } + ds := fileNamed(out, "grafana.ds") + if ds == nil { + t.Fatalf("the only-when-bound file was left out while bound: %v", out) + } + if _, has := ds[OnlyWhenBound]; has { + t.Errorf("the file carries %s to the node-engine", OnlyWhenBound) + } + for _, id := range []string{"grafana." + SecretID("influxdb-api"), "grafana." + BoundID("influxdb-api")} { + if fileNamed(out, id) == nil { + t.Errorf("%s was not delivered while bound", id) + } + } +} diff --git a/go/catalogue/outside_not_given_test.go b/go/catalogue/outside_not_given_test.go new file mode 100644 index 0000000..a63e3d8 --- /dev/null +++ b/go/catalogue/outside_not_given_test.go @@ -0,0 +1,106 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// An own secret issued outside the mesh that nobody gave (novox/hq issue 401, ADR 0228): the mesh makes no value for it, +// so the composition places no file, a file of the module that names it says empty there, and nothing restarts on it. +// Given, it is placed and filled like any own secret. + +func outsideManifest(t *testing.T) Manifest { + t.Helper() + m, err := ParseManifest([]byte(`{"module":"assistant","version":"1", + "own-secrets":{ + "password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"}, + "vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}}, + "resources":[ + {"id":"env","type":"file","path":"/var/lib/assistant/env","mode":"0600", + "content":"PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n"}, + {"id":"server","type":"container","name":"assistant","image":"assistant:1", + "env-file":["/var/lib/assistant/env"], + "volumes":["/var/lib/assistant/vendor-key.secret:/run/vendor-key:ro"], + "secrets-in-environment":"reads its settings from the environment only"}]}`)) + if err != nil { + t.Fatal(err) + } + return m +} + +func resourceAt(out []map[string]any, path string) map[string]any { + for _, r := range out { + if r["path"] == path { + return r + } + } + return nil +} + +func resourceNamed(out []map[string]any, name string) map[string]any { + for _, r := range out { + if r["name"] == name { + return r + } + } + return nil +} + +func TestAnOutsideSecretNotGivenIsNoFileAndAFileNamingItSaysEmpty(t *testing.T) { + r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}} + out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {"password": "sealed-password"}}}) + if err != nil { + t.Fatalf("a module whose outside secret nobody gave did not compose: %v", err) + } + if got := resourceAt(out, "/var/lib/assistant/vendor-key.secret"); got != nil { + t.Fatalf("a secret nobody gave was placed: %v", got) + } + env := resourceAt(out, "/var/lib/assistant/env") + if env == nil { + t.Fatal("the module's own file is not composed") + } + if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=\n" { + t.Fatalf("the file names the secret nobody gave as %q", content) + } + secrets, _ := env["secrets"].(map[string]any) + if len(secrets) != 1 || secrets["password"] != "sealed-password" { + t.Fatalf("the file is filled from %v", secrets) + } + server := resourceNamed(out, "assistant") + if on := strings.Join(stringsIn(server["restart-on"]), " "); strings.Contains(on, "vendor-key") { + t.Fatalf("the container restarts on a secret nobody gave: %q", on) + } +} + +func TestAnOutsideSecretGivenIsPlacedAndFilled(t *testing.T) { + r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}} + out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": { + "password": "sealed-password", "vendor-key": "sealed-vendor-key"}}}) + if err != nil { + t.Fatal(err) + } + placed := resourceAt(out, "/var/lib/assistant/vendor-key.secret") + if placed == nil || placed["sealed"] != "sealed-vendor-key" { + t.Fatalf("the secret given is placed as %v", placed) + } + env := resourceAt(out, "/var/lib/assistant/env") + if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n" { + t.Fatalf("the file was changed: %q", content) + } + if secrets, _ := env["secrets"].(map[string]any); secrets["vendor-key"] != "sealed-vendor-key" { + t.Fatalf("the file is filled from %v", secrets) + } + server := resourceNamed(out, "assistant") + if on := strings.Join(stringsIn(server["restart-on"]), " "); !strings.Contains(on, "assistant."+NeedID("vendor-key")) { + t.Fatalf("the container does not restart on the secret given: %q", on) + } +} + +// A secret the mesh makes is still refused when it was not made: only an outside one is left out. +func TestAnOwnSecretTheMeshMakesIsStillRefusedWhenNotMade(t *testing.T) { + r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}} + _, err := r.Declaration(Rendering{}) + if nm, ok := err.(*NotMadeError); !ok || nm.Name != "password" { + t.Fatalf("a secret the mesh makes, not made: %v", err) + } +} diff --git a/go/catalogue/own_name_test.go b/go/catalogue/own_name_test.go new file mode 100644 index 0000000..f77ac89 --- /dev/null +++ b/go/catalogue/own_name_test.go @@ -0,0 +1,130 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it +// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on +// every other machine. It is told instead, from the same composition the provider receives. + +// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file. +func selfAware(label string) Manifest { + m := labelled("board", label, 8080) + m.Requires = []string{"reverse-proxy"} + m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"} + m.Resources = []map[string]any{ + {"id": "conf", "type": "file", "path": "/var/lib/board/app.conf", + "content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"}, + } + return m +} + +// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to +// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`). +func servingProxy() Manifest { + p := proxy() + p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}} + p.Serves = map[string]map[string]any{"reverse-proxy": {}} + return p +} + +// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from +// `here` rather than from the mesh's needs — the other path a binding is written by. +func nodeProxy() Manifest { + p := proxy() + p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}} + return p +} + +// onBoth is a node with a public domain and a private-network address, so both names compose. +func onBoth(domain string) Node { + n := withDomain(domain) + n.At = "anchor.internal" + return n +} + +func fileAt(t *testing.T, out []map[string]any, path string) string { + t.Helper() + for _, r := range out { + if r["path"] == path { + return r["content"].(string) + } + } + t.Fatalf("nothing was declared at %s", path) + return "" +} + +func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) { + got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"}, + onBoth("example.tld"), World{}) + if err != nil { + t.Fatal(err) + } + out := mustDeclare(t, got) + served := received(t, out)[0].Values + + var binding map[string]any + if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil { + t.Fatal(err) + } + if binding["name"] != served["name"] || binding["name"] != "git.example.tld" { + t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"]) + } + if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil { + t.Fatalf("internal name: module told %v, provider serves %v", + binding["internal-name"], served["internal-name"]) + } + + conf := fileAt(t, out, "/var/lib/board/app.conf") + want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n" + if conf != want { + t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want) + } +} + +func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) { + // The whole point: the same definition, two machines, two names — nothing edited. + for _, domain := range []string{"example.tld", "other.example"} { + got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"}, + onBoth(domain), World{}) + if err != nil { + t.Fatal(err) + } + conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf") + if !strings.HasPrefix(conf, "root = https://git."+domain+"/") { + t.Fatalf("on %s the module wrote %q", domain, conf) + } + } +} + +func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) { + // No public domain on the node: nothing composed, so no `name` — and a file asking for one is + // refused rather than rendered with a placeholder or an empty host. + m := selfAware("git") + m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n" + got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if _, err := got.Declaration(Rendering{}); err == nil || + !strings.Contains(err.Error(), `"name"`) { + t.Fatalf("a file asking for a name that was never composed was not refused: %v", err) + } +} + +func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) { + m := selfAware("git") + m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n" + got, err := Resolve(shelf(nodeProxy(), m), []string{"board"}, + withDomain("example.tld"), World{}) + if err != nil { + t.Fatal(err) + } + conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf") + if !strings.HasPrefix(conf, "root = https://git.example.tld/") { + t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf) + } +} diff --git a/go/catalogue/own_secret_owner_test.go b/go/catalogue/own_secret_owner_test.go new file mode 100644 index 0000000..fba354a --- /dev/null +++ b/go/catalogue/own_secret_owner_test.go @@ -0,0 +1,116 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// An own secret (or a family) may declare `owner` (novox/hq ADR 0436 §1): the account the files holding it +// belong to on the machine, when that account is not the module's secrets-owner default. The fixture is +// mesh-mail's future manifest shape (ADR 0436 §7): a process account `mesh-mail-send` the module makes, and its +// own-secret family `mail-credential-*` owned by it. + +// mailSenderManifest is the fixture every mail-sender issue builds on: the real parts of mesh-mail's manifest +// taken from mesh-catalog/modules/mesh-mail/module.json, extended as ADR 0436 §7-§8 describe, with `own` naming +// the account that owns the family's files. +func mailSenderManifest(t *testing.T, own string) Manifest { + t.Helper() + ownSecrets := `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside"` + if own != "" { + ownSecrets += `,"owner":"` + own + `"` + } + ownSecrets += `}` + m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` + + ownSecrets + `},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) + if err != nil { + t.Fatalf("a manifest with own=%q was refused: %v", own, err) + } + return m +} + +// 1170/AC1: a family owned by the module's own account lands at 0600, owned by that account — and a module's +// other own secrets (no per-secret owner declared) keep secrets-owner, unchanged. +func TestAnOwnSecretsOwnerIsGivenToItsFiles(t *testing.T) { + m := mailSenderManifest(t, "mesh-mail-send") + m.OwnSecrets["smtp-relay-key"] = OwnSecret{Path: "/var/lib/mesh-mail/smtp-relay-key", Taken: TakenAtStart} + r := Resolution{Node: "novox", Modules: []Manifest{m}} + out, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"mesh-mail": {"mail-credential-smtp": "sealed-credential", "smtp-relay-key": "sealed-relay"}}, + }) + if err != nil { + t.Fatalf("composing the declaration: %v", err) + } + var family, plain map[string]any + for _, res := range out { + if res["type"] != "file" { + continue + } + switch res["path"] { + case "/var/lib/mesh-mail/mail-credential-smtp.secret": + family = res + case "/var/lib/mesh-mail/smtp-relay-key": + plain = res + } + } + if family == nil { + t.Fatalf("the family member was not composed at all: %v", out) + } + if got := family["owner"]; got != "mesh-mail-send" { + t.Fatalf("the family member's owner: %v; want mesh-mail-send", got) + } + // review round 1, finding 7: asserted, not merely logged. ownedBy never sets "mode", so a sealed secret + // file carries none here either — the host applies 0600 to every one regardless (novox/hq ADR 0086) — + // and the owner clause must not have added one of its own that could override that. + if _, has := family["mode"]; has { + t.Fatalf("the family member carries its own mode %v; the owner clause must not set one, so the "+ + "host's 0600 for every sealed secret file stands", family["mode"]) + } + if plain == nil { + t.Fatalf("the module's other own secret was not composed at all: %v", out) + } + if got := plain["owner"]; got != "mesh-mail" { + t.Fatalf("a secret with no per-secret owner kept %v; want the module's secrets-owner %q", got, "mesh-mail") + } +} + +// 1170/AC2: an owner naming an account the module does not make, root, or the operator account's placeholder is +// refused, naming the secret and the account. +func TestAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing.T) { + for name, c := range map[string]struct{ owner, says string }{ + "root": {"root", "root"}, + "the operator account": {"${machine:account}", "operator account"}, + "an account not declared": {"not-made", "not-made"}, + } { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{` + + `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside","owner":"` + + c.owner + `"}},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) + if err == nil || !strings.Contains(err.Error(), "mail-credential-*") || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming the secret and %q", name, err, c.says) + } + } +} + +// 1170/AC3: a manifest with no owner on any own secret composes exactly as it did before this clause existed: +// every secret file belongs to the module's secrets-owner (or nothing, when that is absent too). +func TestNoOwnerOnAnySecretComposesAsMainDoes(t *testing.T) { + m := mailSenderManifest(t, "") + r := Resolution{Node: "novox", Modules: []Manifest{m}} + out, err := r.Declaration(Rendering{ + Needed: map[string]map[string]string{"mesh-mail": {"mail-credential-smtp": "sealed-credential"}}, + }) + if err != nil { + t.Fatalf("composing the declaration: %v", err) + } + var found bool + for _, res := range out { + if res["type"] == "file" && res["path"] == "/var/lib/mesh-mail/mail-credential-smtp.secret" { + found = true + if got := res["owner"]; got != "mesh-mail" { + t.Fatalf("with no per-secret owner declared, the file's owner: %v; want secrets-owner %q", got, "mesh-mail") + } + } + } + if !found { + t.Fatalf("the family member was not composed at all: %v", out) + } +} diff --git a/go/catalogue/own_secret_prompt_test.go b/go/catalogue/own_secret_prompt_test.go new file mode 100644 index 0000000..f0d6575 --- /dev/null +++ b/go/catalogue/own_secret_prompt_test.go @@ -0,0 +1,111 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// An own secret (or a family) may declare `prompt`, one line naming what it is for a desk's prompt in place of +// its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in +// the module check. +func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", + "prompt":"the password of the mail account {member}"}, + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":"the key for the outbound relay"}}}`)) + if err != nil { + t.Fatalf("a manifest with a well-formed prompt was refused: %v", err) + } + if got := m.OwnSecrets["mail-credential-*"].Prompt; got != "the password of the mail account {member}" { + t.Fatalf("the family's prompt: %q", got) + } + if got := m.OwnSecrets["smtp-relay-key"].Prompt; got != "the key for the outbound relay" { + t.Fatalf("the plain secret's prompt: %q", got) + } + // Written back the way it was read, so a registered manifest keeps its bytes. + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + var again OwnSecrets + if err := json.Unmarshal(out, &again); err != nil { + t.Fatal(err) + } + if again["mail-credential-*"].Prompt != "the password of the mail account {member}" || + again["smtp-relay-key"].Prompt != "the key for the outbound relay" { + t.Fatalf("the round trip lost the prompt: %s", out) + } +} + +// No prompt declared is fine: the desk asks by name alone, as it does today. +func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{ + "telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`)) + if err != nil { + t.Fatal(err) + } + if m.OwnSecrets["telegram-token"].Prompt != "" { + t.Fatalf("an undeclared prompt: %q", m.OwnSecrets["telegram-token"].Prompt) + } + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "prompt") { + t.Fatalf("an empty prompt was written: %s", out) + } +} + +// PromptMax counts characters, not bytes: a prompt of 100 multi-byte characters is well within it even +// though it holds well over 120 bytes (novox/hq issue 789). +func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { + prompt := strings.Repeat("é", 100) + if n := len(prompt); n <= 120 { + t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n) + } + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":` + jsonString(prompt) + `}}}`)) + if err != nil { + t.Fatalf("a 100-character prompt was refused: %v", err) + } +} + +// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789). +func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":"For {member}"}}}`)) + if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") { + t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err) + } +} + +// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). +func TestAMalformedPromptIsRefused(t *testing.T) { + for name, c := range map[string]struct{ prompt, says string }{ + "a newline": {"the password of\nthe mail account {member}", "one line"}, + "over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " + + "send and receive mail for the operator's account on this machine today", "120 characters"}, + "a placeholder other than {member}": {"the password of the mail account {name}", "{member}"}, + "two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"}, + } { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", + "prompt":` + jsonString(c.prompt) + `}}}`)) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) + } + } +} + +// jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. +func jsonString(s string) string { + out, err := json.Marshal(s) + if err != nil { + panic(err) + } + return string(out) +} diff --git a/go/catalogue/own_secret_taken_test.go b/go/catalogue/own_secret_taken_test.go new file mode 100644 index 0000000..4ad016c --- /dev/null +++ b/go/catalogue/own_secret_taken_test.go @@ -0,0 +1,93 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// An own secret says how the module takes it (novox/hq ADR 0114, issue 180): a path alone says +// nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes +// it was given either way. +func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + "broker":"/var/lib/mesh/idp/broker", + "admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"}, + "session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`)) + if err != nil { + t.Fatal(err) + } + if m.OwnSecrets["broker"] != (OwnSecret{Path: "/var/lib/mesh/idp/broker"}) { + t.Fatalf("a path alone is a path and nothing more: %+v", m.OwnSecrets["broker"]) + } + if m.OwnSecrets["admin"].Taken != TakenApplied || m.OwnSecrets["session"].Taken != TakenAtStart { + t.Fatalf("the word was not kept: %+v", m.OwnSecrets) + } + // Written back the way it was read, so a registered manifest keeps its bytes. + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + var again OwnSecrets + if err := json.Unmarshal(out, &again); err != nil { + t.Fatal(err) + } + if len(again) != 3 || again["admin"].Taken != TakenApplied || again["broker"].Taken != "" { + t.Fatalf("the round trip changed the secrets: %s", out) + } + if !strings.Contains(string(out), `"broker":"/var/lib/mesh/idp/broker"`) { + t.Fatalf("a path alone is written back as a path: %s", out) + } +} + +func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + "admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`)) + if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) { + t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err) + } + _, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + "admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`)) + if err == nil { + t.Fatal("an unknown field on an own secret was accepted") + } +} + +// A secret an outside party issues says so (novox/hq ADR 0228), is written back as it was read, and +// is the one at-start secret the mesh may not make; any other word for who issued it is refused. +func TestAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ + "server-password":{"path":"/var/lib/letta/server-password","taken":"at-start"}, + "openai-api-key":{"path":"/var/lib/letta/openai-api-key","taken":"at-start","issued-by":"outside"}, + "telegram-token":{"path":"/var/lib/letta/telegram-token","issued-by":"outside"}, + "logflare":{"path":"/var/lib/letta/logflare","taken":"applied"}, + "broker":"/var/lib/mesh/letta/broker"}}`)) + if err != nil { + t.Fatal(err) + } + for name, may := range map[string]bool{"server-password": true, "openai-api-key": false, + "telegram-token": false, "logflare": false, "broker": false} { + if got := m.OwnSecrets[name].MeshMayMake(); got != may { + t.Errorf("%s: the mesh may make it = %v, want %v", name, got, may) + } + } + out, err := json.Marshal(m.OwnSecrets) + if err != nil { + t.Fatal(err) + } + var again OwnSecrets + if err := json.Unmarshal(out, &again); err != nil { + t.Fatal(err) + } + if again["openai-api-key"] != m.OwnSecrets["openai-api-key"] || again["telegram-token"] != m.OwnSecrets["telegram-token"] { + t.Fatalf("the round trip lost who issued it: %s", out) + } + if strings.Contains(string(out), `"taken":""`) { + t.Fatalf("a secret that says only who issued it gained an empty taken: %s", out) + } + _, err = ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ + "openai-api-key":{"path":"/var/lib/letta/openai-api-key","issued-by":"openai"}}}`)) + if err == nil || !strings.Contains(err.Error(), `issued by "openai"`) { + t.Fatalf("an unknown word for who issued a secret was accepted: %v", err) + } +} diff --git a/go/catalogue/pin_names_module_test.go b/go/catalogue/pin_names_module_test.go new file mode 100644 index 0000000..8191e5a --- /dev/null +++ b/go/catalogue/pin_names_module_test.go @@ -0,0 +1,115 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Two modules on one node both provide acme-ca — public-acme (Let's Encrypt) and step-ca (the +// mesh's own authority) on novox — and a route-proxy elsewhere must get the public one (novox/hq +// #258). A pin names the module as well as the node, so that it can say which. + +func issuerShelf() map[string]Manifest { + return shelf( + Manifest{Module: "public-acme", Version: "1", Provides: FromAnywhere("acme-ca"), + Serves: map[string]map[string]any{"acme-ca": {"at": "acme-v02.api.letsencrypt.org"}}}, + Manifest{Module: "step-ca", Version: "1", Provides: FromAnywhere("acme-ca"), + Serves: map[string]map[string]any{"acme-ca": {"at": "novox.internal"}}}, + Manifest{Module: "route-proxy", Version: "1", Requires: []string{"acme-ca"}}, + ) +} + +func twoIssuersOnOneNode() map[string][]Provider { + return map[string][]Provider{"acme-ca": { + {Node: "novox", At: "novox.internal", Module: "public-acme", Serves: map[string]any{"at": "acme-v02.api.letsencrypt.org"}}, + {Node: "novox", At: "novox.internal", Module: "step-ca", Serves: map[string]any{"at": "novox.internal"}}, + }} +} + +func TestTwoProvidersOnOneNodeAreRefusedWithBothNamed(t *testing.T) { + // The refusal must name the pair, because a node alone cannot tell them apart. + _, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), + World{Offered: twoIssuersOnOneNode()}) + if err == nil { + t.Fatal("two providers on one node were resolved by picking") + } + for _, want := range []string{"novox/public-acme", "novox/step-ca", " "} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not say %q: %v", want, err) + } + } +} + +func TestAPinNamesTheModule(t *testing.T) { + got, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), + World{Offered: twoIssuersOnOneNode(), + Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "public-acme"}}}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "novox" || got.Needs[0].Serves["at"] != "acme-v02.api.letsencrypt.org" { + t.Fatalf("the named module was not the one taken: %+v", got.Needs) + } +} + +func TestARecordNamingOnlyTheNodeIsRefusedWhenThatNodeAnswersTwice(t *testing.T) { + // A pin from before the module was asked for. It once took the last one listed — a coin flip. + _, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), + World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox"}}}) + if err == nil { + t.Fatal("a node that answers twice was resolved by picking") + } + if !strings.Contains(err.Error(), "provides it 2 times") || !strings.Contains(err.Error(), "pin workstation acme-ca novox ") { + t.Fatalf("the refusal does not ask for the module: %v", err) + } +} + +func TestAPinNamingAModuleThatDoesNotProvideItIsRefused(t *testing.T) { + _, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), + World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "gitea"}}}) + if err == nil || !strings.Contains(err.Error(), "novox/gitea does not provide it") { + t.Fatalf("a module that does not provide it was not refused by name: %v", err) + } +} + +func TestTwoProvidersBesideTheConsumerAreRefusedUntilOneIsNamed(t *testing.T) { + // The same ambiguity on the consumer's own machine. This was settled by a map walk — random, + // per plan — which is how novox's own proxy got its issuer. + _, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), World{}) + if err == nil { + t.Fatal("two providers beside the consumer were resolved by picking") + } + if !strings.Contains(err.Error(), "workstation provides \"acme-ca\" 2 times") || !strings.Contains(err.Error(), "public-acme, step-ca") { + t.Fatalf("the refusal does not list them: %v", err) + } +} + +func TestAPinSettlesTwoProvidersBesideTheConsumer(t *testing.T) { + got, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), + World{Pinned: map[string]Chosen{"acme-ca": {Node: "workstation", Module: "public-acme"}}}) + if err != nil { + t.Fatal(err) + } + var found bool + for _, n := range got.Needs { + if n.Name == "acme-ca" { + found = true + if n.Serves["at"] != "acme-v02.api.letsencrypt.org" { + t.Fatalf("the named module was not the one taken: %+v", n) + } + } + } + if !found { + t.Fatalf("no need for acme-ca was created: %+v", got.Needs) + } +} + +func TestTheFirstPassDoesNotRefuseTwoProvidersBesideTheConsumer(t *testing.T) { + // The first pass answers only what a node offers. Refused there, the node vanishes from every + // other node's world — and the whole mesh loses its vault for an ambiguity one machine has to + // settle. The second pass is where it is refused, and the test above proves it is. + if _, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), + World{Unchecked: true}); err != nil { + t.Fatalf("the first pass refused what only the second may: %v", err) + } +} diff --git a/go/catalogue/placed_paths_test.go b/go/catalogue/placed_paths_test.go new file mode 100644 index 0000000..779c151 --- /dev/null +++ b/go/catalogue/placed_paths_test.go @@ -0,0 +1,180 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// TestPlacedDirectoriesKeepTheirPaths is the check novox/hq issue 119 asks for before a definition +// stops naming where its data lives: a converted manifest, resolved on a node with the default root, +// names exactly the paths the manifest before it named. Data that a service is using must not move +// because a definition stopped saying where it was. +// +// Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now. +// Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared +// whole — not only the directories, but every string a directory's id was written into. Both +// sides are resolved, so a manifest converted in two steps (issue 119, then issue 174) is judged +// against the paths it named, not the text it used to name them with. +func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { + before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE") + if before == "" || after == "" { + t.Skip("set MESH_CATALOGUE_BEFORE and MESH_CATALOGUE to two catalogue checkouts to run this") + } + found, _ := filepath.Glob(filepath.Join(after, "modules", "*", "module.json")) + compared := 0 + for _, path := range found { + module := filepath.Base(filepath.Dir(path)) + old, err := os.ReadFile(filepath.Join(before, "modules", module, "module.json")) + if err != nil { + continue // new since; nothing to keep + } + now, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(old) == string(now) { + continue + } + m, err := ParseManifest(now) + if err != nil { + t.Errorf("%s: %v", module, err) + continue + } + earlier, err := ParseManifest(old) + if err != nil { + t.Errorf("%s before: %v", module, err) + continue + } + var was, is any + if err := json.Unmarshal(old, &was); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(now, &is); err != nil { + t.Fatal(err) + } + // Both sides resolved: the manifest before may itself already place some directories + // (issue 119's conversion), and what must not move is the path a machine sees. + was = resolvedTree(was, dirsFor(earlier, Rendering{}), module, t) + resolved := resolvedTree(is, dirsFor(m, Rendering{}), module, t) + // An access named by id resolves to the path the definition still carries as its default + // (issue 153) — the same rule as a placed directory: an assignment that says nothing moves + // nothing. + was = accessesResolved(was, earlier) + resolved = accessesResolved(resolved, m) + if !reflect.DeepEqual(was, resolved) { + wasJSON, _ := json.MarshalIndent(was, "", " ") + isJSON, _ := json.MarshalIndent(resolved, "", " ") + t.Errorf("%s: resolved on the default root, the converted manifest is not the one before it\n--- before\n%s\n--- resolved now\n%s", + module, firstDifference(string(wasJSON), string(isJSON)), "") + } + compared++ + } + t.Logf("%d converted manifest(s) resolve to the paths they named before", compared) +} + +// resolvedTree is the manifest as a machine would see it: every ${dir:…} filled, a pathless +// directory given the path it resolves to, and the placement word removed. +func resolvedTree(node any, dirs map[string]string, module string, t *testing.T) any { + switch v := node.(type) { + case map[string]any: + out := map[string]any{} + for k, child := range v { + if k == "place" { + continue + } + out[k] = resolvedTree(child, dirs, module, t) + } + if out["type"] == "directory" { + if _, has := out["path"]; !has { + if id, ok := out["id"].(string); ok { + out["path"] = dirs[id] + } + } + } + return out + case []any: + out := make([]any, len(v)) + for i, child := range v { + out[i] = resolvedTree(child, dirs, module, t) + } + return out + case string: + filled, err := dirFill(v, dirs, module) + if err != nil { + t.Error(err) + } + return filled + } + return node +} + +func firstDifference(a, b string) string { + al, bl := strings.Split(a, "\n"), strings.Split(b, "\n") + for i := range al { + if i >= len(bl) || al[i] != bl[i] { + from := i - 2 + if from < 0 { + from = 0 + } + to := i + 3 + if to > len(al) { + to = len(al) + } + bt := i + 3 + if bt > len(bl) { + bt = len(bl) + } + return "before:\n" + strings.Join(al[from:to], "\n") + "\nnow:\n" + strings.Join(bl[from:bt], "\n") + } + } + return "(the difference is beyond the shorter document)" +} + +// accessesResolved fills every ${access:} with the default path the definition carries for that +// access, and drops the id, so a manifest that names its accesses is compared by the paths a machine +// with no placement receives. +func accessesResolved(node any, m Manifest) any { + defaults := map[string]string{} + for _, a := range m.Accesses { + if a.ID != "" && a.Path != "" { + defaults[a.ID] = a.Path + } + } + var walk func(any) any + walk = func(n any) any { + switch v := n.(type) { + case map[string]any: + out := map[string]any{} + for k, child := range v { + if k == "id" { + if _, isAccess := v["mode"]; isAccess && v["type"] == nil { + if _, hasPath := v["path"]; hasPath { + continue + } + } + } + out[k] = walk(child) + } + return out + case []any: + out := make([]any, len(v)) + for i, child := range v { + out[i] = walk(child) + } + return out + case string: + return accessRef.ReplaceAllStringFunc(v, func(ref string) string { + if p, ok := defaults[accessRef.FindStringSubmatch(ref)[1]]; ok { + return p + } + return ref + }) + } + return n + } + return walk(node) +} diff --git a/go/catalogue/placement_test.go b/go/catalogue/placement_test.go new file mode 100644 index 0000000..1b19d70 --- /dev/null +++ b/go/catalogue/placement_test.go @@ -0,0 +1,282 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The case novox/hq issue 153 records: an adopted machine keeps its data where the predecessor put +// it — a library on its own pool that must never move, a configuration on a second disk owned by +// whoever the predecessor ran as. A definition may name none of that (ADR 0112); the assignment +// says it, by the ids the definition declared, and the machine receives concrete paths as always. +func placeable() Manifest { + m := mod("arr", nil, nil, nil) + m.Resources = []map[string]any{ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "config", "type": "directory", "mode": "0755", "owner": "1000:1000"}, + {"id": "server", "type": "container", "name": "arr", "image": "arr:1", + "volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:spool}:/downloads:ro"}, + "env": map[string]any{"SPOOL": "${access:spool}"}}, + } + m.Accesses = []Access{{ID: "series", Mode: AccessReadWrite}, {ID: "spool"}} + return m +} + +func placedBy(values map[string]any) Rendering { + return Rendering{Settings: SettingsBy{"arr": {LayerOf("node anchor", values)}}} +} + +func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) { + got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(placedBy(map[string]any{ + PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/services/arr/config/", "owner": "1001:2000"}, + }, + AccessesSetting: map[string]any{ + "series": "/storage/media/series", + "spool": "/storage/downloads", + }, + })) + if err != nil { + t.Fatal(err) + } + seen := map[string]map[string]any{} + for _, r := range out { + seen[r["id"].(string)] = r + } + config := seen["arr.config"] + if config["path"] != "/services/arr/config" || config["owner"] != "1001:2000" { + t.Fatalf("the placed directory is %v %v; want the assignment's path and owner", config["path"], config["owner"]) + } + if seen["arr.state"]["path"] != "/var/lib/arr" { + t.Fatalf("an unplaced directory left the default layout: %v", seen["arr.state"]["path"]) + } + var accesses []string + for _, r := range out { + if r["type"] == "access" { + accesses = append(accesses, r["path"].(string)+" "+r["mode"].(string)) + } + } + if strings.Join(accesses, ",") != "/storage/media/series read-write,/storage/downloads read" { + t.Fatalf("the accesses reached the machine as %v", accesses) + } + server := seen["arr.server"] + mounts := server["volumes"].([]any) + if mounts[0] != "/services/arr/config:/config" || mounts[1] != "/storage/media/series:/series" || + mounts[2] != "/storage/downloads:/downloads:ro" { + t.Fatalf("the mounts were not filled with the placed paths: %v", mounts) + } + if server["env"].(map[string]any)["SPOOL"] != "/storage/downloads" { + t.Fatalf("the environment was not filled: %v", server["env"]) + } +} + +// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the +// setting to write — not mounted as the literal, not skipped. The refusal costs the module its +// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6). +func TestAnUnplacedAccessIsRefusedByName(t *testing.T) { + got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + with := placedBy(map[string]any{ + AccessesSetting: map[string]any{"series": "/storage/media/series"}, + }) + composed, err := got.Compose(with) + if err != nil { + t.Fatal(err) + } + why := composed.LeftOut["arr"] + if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) { + t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut) + } + if _, declared := byID(composed.Resources)["arr.server"]; declared { + t.Fatal("the module with the unplaced access was declared anyway") + } +} + +// Validated like endpoints: an id the module does not declare reaches nothing, and the refusal +// says what it does declare; a relative path and a non-numeric owner are refused too. +func TestPlacementsAreValidated(t *testing.T) { + m := placeable() + layers := func(values map[string]any) []Layer { return placedBy(values).Settings["arr"] } + + _, err := Places(m, layers(map[string]any{PlacesSetting: map[string]any{"data": "/mnt/data"}})) + if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"config"`) { + t.Fatalf("placing an undeclared directory was accepted: %v", err) + } + _, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{"config": "services/arr"}})) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("a relative placement was accepted: %v", err) + } + _, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/services/arr", "owner": "media"}}})) + if err == nil || !strings.Contains(err.Error(), "uid:gid") { + t.Fatalf("a non-numeric owner was accepted: %v", err) + } + _, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"movies": "/storage/media/movies"}})) + if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"series"`) { + t.Fatalf("placing an undeclared access was accepted: %v", err) + } + _, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"series": "media/series"}})) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("a relative access was accepted: %v", err) + } + // And the two keys are never stray: they are validated here, not merged into a file. + if stray := UnusedSettings(m, layers(map[string]any{ + PlacesSetting: map[string]any{"config": "/services/arr/config"}, + AccessesSetting: map[string]any{"series": "/storage/media/series"}, + })); len(stray) != 0 { + t.Fatalf("the placement keys were reported as unused: %v", stray) + } +} + +// A definition that carries a path still works, as the default the assignment may replace — and +// the assignment's placement wins where both say. +func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) { + m := placeable() + m.Accesses = []Access{{ID: "series", Path: "/services/media/series", Mode: AccessReadWrite}, {ID: "spool", Path: "/services/media/downloads"}} + got, err := Resolve(shelf(m), []string{"arr"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(placedBy(map[string]any{ + PlacesSetting: map[string]any{"config": "/services/arr/config"}, + AccessesSetting: map[string]any{"series": "/storage/media/series"}, + })) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, r := range out { + if r["type"] == "access" { + paths = append(paths, r["path"].(string)) + } + } + if strings.Join(paths, ",") != "/storage/media/series,/services/media/downloads" { + t.Fatalf("placed one, defaulted the other: got %v", paths) + } +} + +// A reference to an access the definition does not declare is refused where the author is. +func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { + _, err := ParseManifest([]byte(`{ + "module": "arr", "version": "1", + "accesses": [{"id": "series", "mode": "read-write"}], + "resources": [ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "server", "type": "container", "name": "arr", "image": "arr:1", + "volumes": ["${access:movies}:/movies"]} + ]}`)) + if err == nil || !strings.Contains(err.Error(), "${access:movies}") { + t.Fatalf("a reference to an undeclared access was accepted: %v", err) + } + _, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`)) + if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") { + t.Fatalf("an access with no id and no path was accepted: %v", err) + } +} + +// **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue +// 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A +// mergeable file of today's form still takes every key of the layer as it is said, placement too, so it +// composes as it did while placement was kept in the layer. +func TestPlacementIsReadFromThePlacementOnly(t *testing.T) { + m := placeable() + said := map[string]any{ + PlacesSetting: map[string]any{"config": "/services/arr/config"}, + AccessesSetting: map[string]any{"series": "/storage/media/series", "spool": "/storage/downloads"}, + "log": "warn", + } + among := []Layer{{From: "anchor", Values: said}} + if placed, err := Places(m, among); err != nil || placed != nil { + t.Fatalf("a place among the config values placed %v (%v)", placed, err) + } + if _, _, err := accessesFor(m, among); err == nil { + t.Fatal("an access among the config values placed it") + } + beside := []Layer{LayerOf("anchor", said)} + if beside[0].Values["log"] != "warn" || beside[0].Values[PlacesSetting] != nil || beside[0].Placement[PlacesSetting] == nil { + t.Fatalf("LayerOf split %+v", beside[0]) + } + if placed, err := Places(m, beside); err != nil || placed["config"].Path != "/services/arr/config" { + t.Fatalf("the placement did not place: %v (%v)", placed, err) + } + + file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"} + whole, err := ApplySettings(file, among) + if err != nil { + t.Fatal(err) + } + split, err := ApplySettings(file, beside) + if err != nil { + t.Fatal(err) + } + if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"places"`) { + t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"]) + } +} + +// **The four words of where an assignment is reached are placement too** (novox/hq ADR 0348 clause 11, issue 736). +// `endpoints`, `expose`, `reach` and `ports` among a layer's config values say nothing to the filter, the ports or +// the names; the same in Placement do, and LayerOf puts them there. A mergeable file of today's form still takes +// every key of the layer as it is said, so it composes as it did while they were kept among the values. +func TestTheFourWordsAreReadFromThePlacementOnly(t *testing.T) { + m := Manifest{Module: "forge", Version: "1", + Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp", From: FromMesh}, {Name: "ssh", Port: 22, Protocol: "tcp", From: FromMesh}, + {Name: "api", Port: 8086, Protocol: "tcp", From: FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "image": "x", "ports": []any{"3000", "222:22", "8086"}}}} + said := map[string]any{ + ExposeSetting: map[string]any{"22": FromEverywhere}, + PortsSetting: map[string]any{"22": float64(2222)}, + ReachSetting: map[string]any{"3000": "internal"}, + EndpointsSetting: map[string]any{"api": map[string]any{"label": "influx", "port": float64(18086), "reach": "public"}}, + "log": "warn", + } + among := []Layer{{From: "anchor", Values: said}} + if e, err := Exposure(m, among); err != nil || e != nil { + t.Fatalf("an expose among the config values exposed %v (%v)", e, err) + } + if g, err := GivenPorts(m, among); err != nil || g != nil { + t.Fatalf("a ports among the config values gave %v (%v)", g, err) + } + if r, err := Reaches(m, among); err != nil || r != nil { + t.Fatalf("a reach among the config values reached %v (%v)", r, err) + } + if b, err := Endpoints(m, among); err != nil || len(b) != 0 { + t.Fatalf("an endpoints among the config values configured %v (%v)", b, err) + } + + beside := []Layer{LayerOf("anchor", said)} + if len(beside[0].Values) != 1 || beside[0].Values["log"] != "warn" || len(beside[0].Placement) != 4 { + t.Fatalf("LayerOf split %+v", beside[0]) + } + if e, err := Exposure(m, beside); err != nil || e[22] != FromEverywhere { + t.Fatalf("the placement did not expose: %v (%v)", e, err) + } + if g, err := GivenPorts(m, beside); err != nil || g[22] != 2222 || g[8086] != 18086 { + t.Fatalf("the placement did not give the ports: %v (%v)", g, err) + } + if r, err := Reaches(m, beside); err != nil || r[3000] != "internal" || r[8086] != "public" { + t.Fatalf("the placement did not reach: %v (%v)", r, err) + } + if b, err := Endpoints(m, beside); err != nil || b["api"].Label != "influx" { + t.Fatalf("the placement did not configure the endpoint: %v (%v)", b, err) + } + + file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"} + whole, err := ApplySettings(file, among) + if err != nil { + t.Fatal(err) + } + split, err := ApplySettings(file, beside) + if err != nil { + t.Fatal(err) + } + if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"endpoints"`) { + t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"]) + } +} diff --git a/go/catalogue/port_into_test.go b/go/catalogue/port_into_test.go new file mode 100644 index 0000000..1e13cb5 --- /dev/null +++ b/go/catalogue/port_into_test.go @@ -0,0 +1,170 @@ +package catalogue + +import ( + "reflect" + "strconv" + "strings" + "testing" +) + +// **A process binds the port the mesh gave it, not the one it wrote down.** +// +// A container never needed this: the mesh rewrites its `ports` into assigned:wanted, so the +// software binds the number it always bound and the machine publishes another. A process runs on +// the machine with nothing to rewrite, so without a way to ask, every process binds the number in +// its own configuration and two modules declaring the same one collide — which is the whole +// problem ADR 0038 exists to prevent, reintroduced by the resource kind that most needs it. +func TestAModuleIsToldWhichPortItWasGiven(t *testing.T) { + file := map[string]any{ + "type": "file", "id": "settings", + "content": "LISTEN=${port:8080}\n", + } + listens := []Listening{{Port: 8080, From: FromMesh}} + with := Rendering{Ports: map[string]map[int]int{"showcase": {8080: 21000}}} + + if err := portInto(file, "showcase", listens, with); err != nil { + t.Fatal(err) + } + if got := file["content"].(string); got != "LISTEN=21000\n" { + t.Fatalf("the module was not told its assigned port: %q", got) + } +} + +// With nothing assigned yet, it is told the port it asked about — so a mesh that has not made an +// assignment still composes something coherent rather than writing a zero. +func TestWithNoAssignmentAModuleIsToldWhatItAskedFor(t *testing.T) { + file := map[string]any{"type": "file", "content": "LISTEN=${port:8080}\n"} + if err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{}); err != nil { + t.Fatal(err) + } + if got := file["content"].(string); got != "LISTEN=8080\n" { + t.Fatalf("an unassigned port did not fall back to what was declared: %q", got) + } +} + +// **Asking about a port it never declared is refused**, and the refusal says what it did declare. +// The module is asking about something the mesh has no opinion on, and answering would be a guess +// written into a configuration file as a port number. +func TestAskingAboutAnUndeclaredPortIsRefused(t *testing.T) { + file := map[string]any{"type": "file", "content": "LISTEN=${port:9999}\n"} + err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{}) + if err == nil { + t.Fatal("a module was told a port it never said it listens on") + } + if !strings.Contains(err.Error(), "8080") { + t.Fatalf("the refusal does not say what would have worked: %v", err) + } +} + +// And a file mentioning no port is left exactly as it was. +func TestAFileWithNoPortIsUntouched(t *testing.T) { + file := map[string]any{"type": "file", "content": "GREETING=hello\n"} + if err := portInto(file, "showcase", nil, Rendering{}); err != nil { + t.Fatal(err) + } + if got := file["content"].(string); got != "GREETING=hello\n" { + t.Fatalf("a file with no port was changed: %q", got) + } +} + +// **A container that dials the machine is told the same thing, in its environment.** +// +// The forge's own sidecar reaches the forge over the machine's loopback (novox/hq 04-ISSUES/088). +// The `assigned:wanted` mapping that lets the forge itself go on binding 3000 does nothing for the +// caller: the caller dials the machine, so it must be given the machine's number — here one the +// node was given rather than one the mesh assigned, which is the case that makes the literal wrong +// even on a mesh that never allocates (ADR 0100). +func TestAContainerIsToldWhichPortItWasGiven(t *testing.T) { + sidecar := map[string]any{ + "type": "container", "id": "runtime", "name": "mesh-gitea", + "env": map[string]any{ + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_ADMIN_USER": "mesh-admin", + }, + } + listens := []Listening{{Port: 3000, From: FromMesh}} + with := Rendering{Given: map[string]map[int]int{"gitea": {3000: 2999}}} + + if err := portInto(sidecar, "gitea", listens, with); err != nil { + t.Fatal(err) + } + env := sidecar["env"].(map[string]any) + if env["MESH_GITEA_URL"] != "http://127.0.0.1:2999" { + t.Fatalf("the sidecar dials %v, not the port this machine puts the forge on", + env["MESH_GITEA_URL"]) + } + if env["MESH_ADMIN_USER"] != "mesh-admin" { + t.Fatalf("filling one value changed another: %v", env) + } +} + +// **And the manifest it came out of is left alone.** +// +// An `env` map is the catalogue's, shared by every node running the module, and the resource +// around it is a shallow copy. Filled in place, the first node composed would write its own port +// into the catalogue and every node composed after it would be told that one — a fault that is +// invisible in a single composition and wrong in every mesh with two machines. +func TestFillingAContainersEnvironmentLeavesTheManifestAlone(t *testing.T) { + env := map[string]any{"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}"} + manifest := map[string]any{"type": "container", "id": "runtime", "name": "mesh-gitea", "env": env} + + for _, at := range []int{2999, 3100} { + copied := map[string]any{} + for k, v := range manifest { + copied[k] = v + } + with := Rendering{Given: map[string]map[int]int{"gitea": {3000: at}}} + if err := portInto(copied, "gitea", []Listening{{Port: 3000}}, with); err != nil { + t.Fatal(err) + } + want := "http://127.0.0.1:" + strconv.Itoa(at) + if got := copied["env"].(map[string]any)["MESH_GITEA_URL"]; got != want { + t.Fatalf("the second node was told %v, not %v — the first composition wrote its own "+ + "port into the catalogue", got, want) + } + } + if env["MESH_GITEA_URL"] != "http://127.0.0.1:${port:3000}" { + t.Fatalf("the module's own manifest was edited: %v", env) + } +} + +// Asking in an environment about a port it never declared is refused exactly as a file's is, and +// the refusal names the container and the variable — there is no line number to go on. +func TestAContainerAskingAboutAnUndeclaredPortIsRefused(t *testing.T) { + sidecar := map[string]any{ + "type": "container", "id": "runtime", "name": "mesh-gitea", + "env": map[string]any{"MESH_GITEA_URL": "http://127.0.0.1:${port:9999}"}, + } + err := portInto(sidecar, "gitea", []Listening{{Port: 3000}}, Rendering{}) + if err == nil { + t.Fatal("a container was told a port its module never said it listens on") + } + for _, said := range []string{"mesh-gitea", "MESH_GITEA_URL", "3000"} { + if !strings.Contains(err.Error(), said) { + t.Errorf("the refusal does not say %q: %v", said, err) + } + } +} + +// And a container naming no port keeps the environment it was written with — the same map, so +// nothing is copied and no other node's composition is disturbed. +func TestAContainerWithNoPortPlaceholderKeepsItsEnvironment(t *testing.T) { + env := map[string]any{"MESH_GITEA_URL": "http://gitea:3000", "PORT": 3000} + sidecar := map[string]any{"type": "container", "name": "mesh-gitea", "env": env} + if err := portInto(sidecar, "gitea", []Listening{{Port: 3000}}, Rendering{ + Given: map[string]map[int]int{"gitea": {3000: 2999}}, + }); err != nil { + t.Fatal(err) + } + // A sibling reached over the runtime's own network is reached on the port INSIDE it, which the + // module does control: a literal there is right, and moving it would break the one address the + // mapping does not touch. + if got := sidecar["env"].(map[string]any)["MESH_GITEA_URL"]; got != "http://gitea:3000" { + t.Fatalf("an address on the runtime's own network was moved to the machine's port: %v", got) + } + // The same map, not a copy of it: a container that asks for nothing is left alone, and + // comparing the contents would say yes even to a rebuilt map. + if reflect.ValueOf(sidecar["env"]).Pointer() != reflect.ValueOf(env).Pointer() { + t.Fatalf("a container that asked for nothing had its environment rebuilt: %v", sidecar["env"]) + } +} diff --git a/go/catalogue/predefined_test.go b/go/catalogue/predefined_test.go new file mode 100644 index 0000000..015a8e5 --- /dev/null +++ b/go/catalogue/predefined_test.go @@ -0,0 +1,175 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661): read strictly, and every +// manifest naming a key judged against the list of the tree it is read in. + +// theFirstKeys is the catalogue's first list: font-size and agent-managed-settings. +const theFirstKeys = `{ + "font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72}, + "agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true} +}` + +func TestTheFirstListIsRead(t *testing.T) { + list, err := ParsePredefined([]byte(theFirstKeys)) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(list.Keys(), ","); got != "agent-managed-settings,font-size" { + t.Fatalf("keys %s", got) + } + if k := list["font-size"]; k.Type != ConfigNumber || *k.Minimum != 6 || *k.Maximum != 72 || k.Trusted { + t.Fatalf("font-size read as %+v", k) + } + if k := list["agent-managed-settings"]; k.Type != ConfigObject || !k.Trusted { + t.Fatalf("agent-managed-settings read as %+v", k) + } + // One list is one text, however it was written. + a, _ := list.Canonical() + again, _ := ParsePredefined(a) + b, _ := again.Canonical() + if string(a) != string(b) { + t.Fatalf("canonical text moved:\n%s\n%s", a, b) + } +} + +func TestAListIsRefusedWholeForAnyWrongKey(t *testing.T) { + for _, c := range []struct{ file, says string }{ + {`[]`, "not a JSON object of keys"}, + {`null`, "not a JSON object of keys"}, + {`{"font-size": {"type": "number", "description": "points", "default": 10}}`, `unknown field "default"`}, + {`{"font-size": {"description": "points"}}`, "no type"}, + {`{"font-size": {"type": "decimal", "description": "points"}}`, `type "decimal"`}, + {`{"font-size": {"type": "number"}}`, "no description"}, + {`{"accent": {"type": "object", "description": "colours"}}`, "not trusted"}, + {`{"font-size": {"type": "number", "description": "points", "minimum": 9, "maximum": 6}}`, "above its maximum"}, + {`{"accent": {"type": "string", "description": "a name", "pattern": "("}}`, "not a regular expression"}, + {`{"accent": {"type": "boolean", "description": "on or off", "minimum": 1}}`, "minimum or maximum is for a number"}, + {`{"Font Size": {"type": "number", "description": "points"}}`, "not a usable key"}, + {`{"places": {"type": "string", "description": "where"}}`, "placement"}, + {`{"puid": {"type": "number", "description": "an id"}}`, "placement"}, + } { + if _, err := ParsePredefined([]byte(c.file)); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: wanted a refusal saying %q, got %v", c.file, c.says, err) + } + } +} + +// fallingBackTo is inConfigForm with its font size falling back to a predefined key. +func fallingBackTo(t *testing.T, key string) []byte { + t.Helper() + m := inConfigForm() + declaration(m, "font-size")["predefined"] = key + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +// A manifest naming font-size registers against the list; one naming a key not on it, or of another type, is +// refused — judged against the list given, whatever the mesh published. +func TestAManifestIsJudgedAgainstTheListOfItsTree(t *testing.T) { + withPredefined(t, nil) + list, err := ParsePredefined([]byte(theFirstKeys)) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifestWith(fallingBackTo(t, "font-size"), list); err != nil { + t.Fatalf("a value falling back to font-size is refused: %v", err) + } + if _, err := ParseManifestWith(fallingBackTo(t, "font-scale"), list); err == nil || + !strings.Contains(err.Error(), `"font-scale", which is not on the list`) { + t.Fatalf("a key not on the list: %v", err) + } + m := inConfigForm() + declaration(m, "theme")["predefined"] = "font-size" + raw, _ := json.Marshal(m) + if _, err := ParseManifestWith(raw, list); err == nil || !strings.Contains(err.Error(), "which is a number") { + t.Fatalf("a key of another type: %v", err) + } + // Where no tree is read, the published list judges: none published refuses. + if _, err := ParseManifest(fallingBackTo(t, "font-size")); err == nil || !strings.Contains(err.Error(), "is not present") { + t.Fatalf("judged against no list: %v", err) + } + withPredefined(t, list) + if _, err := ParseManifest(fallingBackTo(t, "font-size")); err != nil { + t.Fatalf("judged against the published list: %v", err) + } +} + +// A template's text filled from the tree keeps the list the manifest was judged against: read again, it is not +// judged against the published one. +func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { + withPredefined(t, nil) + list, _ := ParsePredefined([]byte(theFirstKeys)) + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, DefaultTemplatesDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, DefaultTemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil { + t.Fatal(err) + } + m := inConfigForm() + declaration(m, "font-size")["predefined"] = "font-size" + r := resourceOf(m, "configuration") + delete(r, "content") + r["template"] = DefaultTemplatesDir + "/rc" + m["build"] = map[string]any{"artifacts": []any{map[string]any{"name": "files", "kind": "archive", "from": "files"}}} + declaration(m, "width")["runtime"] = true + declaration(m, "sender")["runtime"] = true + raw, _ := json.Marshal(m) + parsed, err := ParseManifestWith(raw, list) + if err != nil { + t.Fatal(err) + } + if _, err := WithTemplatesFrom(parsed, dir); err != nil { + t.Fatalf("filled from the tree, it was judged against another list: %v", err) + } +} + +// module check reads the list at the root of the repository above the manifest; no repository, no list. +func TestTheListIsFoundAtTheRepositoryRootAboveAManifest(t *testing.T) { + root := t.TempDir() + module := filepath.Join(root, "modules", "dunst") + if err := os.MkdirAll(module, 0o755); err != nil { + t.Fatal(err) + } + if list, err := PredefinedAbove(module); err != nil || list != nil { + t.Fatalf("outside a repository: %v %v", list, err) + } + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if list, err := PredefinedAbove(module); err != nil || list != nil { + t.Fatalf("a repository without the file: %v %v", list, err) + } + if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(theFirstKeys), 0o644); err != nil { + t.Fatal(err) + } + list, err := PredefinedAbove(module) + if err != nil || len(list) != 2 { + t.Fatalf("the list above: %v %v", list, err) + } + if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(`{"font-size": {}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := PredefinedAbove(module); err == nil { + t.Fatal("a wrong list above was read") + } + if err := os.Remove(filepath.Join(root, PredefinedFile)); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/etc/hostname", filepath.Join(root, PredefinedFile)); err == nil { + if _, err := PredefinedAbove(module); err == nil || !strings.Contains(err.Error(), "not a plain file") { + t.Fatalf("a link read as the list: %v", err) + } + } +} diff --git a/go/catalogue/prepares_test.go b/go/catalogue/prepares_test.go new file mode 100644 index 0000000..eaae43b --- /dev/null +++ b/go/catalogue/prepares_test.go @@ -0,0 +1,141 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +// A module version prepares its state before it runs (novox/hq ADR 0135). +// +// What the mesh derives is the module's own resource, run once with one word, placed immediately in +// front of the thing it prepares for. What matters in these tests is that the derivation is a copy +// rather than a second description: the failure it replaces was a hand-written step repeating six +// fields of the resource it preceded, each free to drift from it. + +func aPreparingModule() Manifest { + return Manifest{ + Module: "gitea", + Prepares: true, + Resources: []map[string]any{ + {"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"}, + {"id": "server", "type": "container", "name": "mesh-gitea-server", + "image": "gitea/gitea@" + digest, "ports": []any{"3000:3000"}}, + {"id": "runtime", "type": "container", "name": "mesh-gitea", + "image": ArtifactStoreScheme + "gitea/runtime@" + digest, "network": "host", + "env": map[string]any{"MESH_GITEA_STATE_DIR": "/run/state"}, + "volumes": []any{"/var/lib/gitea:/run/state:ro"}, + "ports": []any{"9000:9000"}}, + }, + } +} + +func declaredFor(t *testing.T, m Manifest) []map[string]any { + t.Helper() + // The manifest as the mesh holds it: a build resolved the module's own artifact into the + // reference it recorded, which is also how the composition knows whose code a resource runs. + out, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration( + Rendering{ArtifactStore: "anchor.internal:5100"}) + if err != nil { + t.Fatal(err) + } + return out +} + +func idsOf(resources []map[string]any) []string { + var ids []string + for _, r := range resources { + ids = append(ids, fmt.Sprint(r["id"])) + } + return ids +} + +// The step runs the module's own code, and comes immediately before it — not before the upstream +// server the module packages, which may be the very thing the state lives in. +func TestThePreparationRunsTheModulesOwnCodeAndComesRightBeforeIt(t *testing.T) { + out := declaredFor(t, aPreparingModule()) + ids := idsOf(out) + at := -1 + for i, id := range ids { + if id == "gitea.runtime-prepare" { + at = i + } + } + if at < 0 { + t.Fatalf("nothing prepares this module's state: %v", ids) + } + // A module's name may contain a dot, so a resource's module is everything before the last one — + // which the derived id must not add to, or a machine reads the wrong owner from it. + if strings.Count("gitea.runtime-prepare", ".") != 1 { + t.Fatal("the derived id adds a dot, so what owns it cannot be read from it") + } + if ids[at+1] != "gitea.runtime" { + t.Fatalf("the preparation is not immediately before the module's own code: %v", ids) + } + for _, id := range ids[:at] { + if id == "gitea.runtime" { + t.Fatalf("the module's own code runs before its state is prepared: %v", ids) + } + } +} + +// It is given exactly what the module's own code is given. Asserted field by field against the +// resource it was derived from, because writing it twice is the fault this replaces. +func TestThePreparationIsGivenWhatTheModuleIsGiven(t *testing.T) { + out := declaredFor(t, aPreparingModule()) + declared := byID(out) + step, workload := declared["gitea.runtime-prepare"], declared["gitea.runtime"] + if step == nil || workload == nil { + t.Fatalf("expected both, got %v", idsOf(out)) + } + for _, field := range []string{"image", "network", "env", "volumes", "type"} { + if fmt.Sprint(step[field]) != fmt.Sprint(workload[field]) { + t.Errorf("the preparation's %s is %v and the module's is %v", field, step[field], workload[field]) + } + } + if once, _ := step["run-once"].(bool); !once { + t.Error("the preparation is not a step, so nothing waits for it and nothing is gated by it") + } + if fmt.Sprint(step["args"]) != fmt.Sprint([]any{PreparationArgument}) { + t.Errorf("the preparation is asked for as %v", step["args"]) + } + if fmt.Sprint(step["name"]) == fmt.Sprint(workload["name"]) { + t.Error("the preparation and the workload have one name, so one removes the other") + } + // A published port cannot be bound twice, and the version being replaced is still running. + if _, published := step["ports"]; published { + t.Errorf("the preparation publishes a port the running version holds: %v", step["ports"]) + } +} + +// A module that says nothing about preparing gets nothing, which is most modules. +func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) { + m := aPreparingModule() + m.Prepares = false + for _, id := range idsOf(declaredFor(t, m)) { + if id == "gitea.runtime-prepare" { + t.Fatal("a module that prepares nothing was given a preparation") + } + } +} + +// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest +// is read beats a declaration that quietly prepares nothing. +func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) { + m := Manifest{ + Module: "gitea", + Prepares: true, + Resources: []map[string]any{ + // Only the upstream server it packages: nothing here runs gitea's own code. + {"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest}, + }, + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifest(raw); err == nil { + t.Fatal("a module that prepares its state with nothing of its own to run was accepted") + } +} diff --git a/go/catalogue/print_rehearsal_test.go b/go/catalogue/print_rehearsal_test.go new file mode 100644 index 0000000..d2aa7ef --- /dev/null +++ b/go/catalogue/print_rehearsal_test.go @@ -0,0 +1,16 @@ +package catalogue + +import ( + "os" + "testing" +) + +func TestPrintRehearsalRuleset(t *testing.T) { + if os.Getenv("PRINT_RULESET") == "" { + t.Skip("set PRINT_RULESET") + } + rules := mustFilter(t, Resolution{Modules: []Manifest{ + {Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}}, + }}, nil) + t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil, "mesh0")) +} diff --git a/go/catalogue/process_bus_test.go b/go/catalogue/process_bus_test.go new file mode 100644 index 0000000..5551ec1 --- /dev/null +++ b/go/catalogue/process_bus_test.go @@ -0,0 +1,191 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A process resource may declare `bus` (novox/hq ADR 0436 §1): what it emits, the module's own work-queue +// streams it consumes, the module's own state it reads and writes, whether it reads the router's records of +// this module's own asks, and the tools and seat verbs it calls. The fixture is mesh-mail's future manifest +// shape (ADR 0436 §7, §8), copied from mesh-catalog/modules/mesh-mail/module.json and extended as settled by +// the lead: process id "send" is the manifest's own `id` (internal/catalogue/runtime.go:72-74 is the model for +// a process id); the account file uses the controller's real JSON tags (modules.go:1041-1056 issueWith). +func mailSenderProcessManifest(t *testing.T, bus string) string { + t.Helper() + process := `{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send"` + if bus != "" { + process += `,"bus":` + bus + } + process += `}` + // The stream itself is plain here, not retention work-queue: 1179 checks only that a process names a + // stream the module owns, never its retention — that rule is 1178's (WorkQueueStreamProblems), and is + // tested against its own fixture there (work_queue_consumer_test.go) so this one stays accepted whether + // or not a process currently names "mail-sends" in bus.queues. + return `{"module":"mesh-mail","version":"1","emits":["mail.send.sent","mail.send.failed"],` + + `"state":["sends"],"streams":[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,` + + `"max-mib":64}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + process + `]}` +} + +const mailSenderBus = `{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],` + + `"state":["sends"],"asks":true,"invokes":["seat:operator-channel.tell"]}` + +// 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue +// gives it whole to the grant composition (ProcessBuses, which 1180 reads). +func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { + m, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) + if err != nil { + t.Fatalf("a well-formed process bus was refused: %v", err) + } + buses := m.ProcessBuses() + b, ok := buses["send"] + if !ok { + t.Fatalf("the process's bus was not given to the grant composition: %+v", buses) + } + if !strSliceEqual(b.Emits, []string{"mail.send.sent", "mail.send.failed"}) || + !strSliceEqual(b.Queues, []string{"mail-sends"}) || !strSliceEqual(b.State, []string{"sends"}) || + !b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) { + t.Fatalf("the bus read as %+v", b) + } +} + +func strSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// 1179/AC2: a bus on a process with no user, user root, or the operator account is refused naming the +// process and the account rule. +func TestAProcessBusAccountMustBeTheModulesOwn(t *testing.T) { + for name, c := range map[string]struct{ user, says string }{ + "no user": {"", "send"}, + "user root": {"root", "root"}, + "the operator account": {"${machine:account}", "operator"}, + } { + raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` + + `"streams":[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + + `"max-deliveries":5}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + + `{"id":"send","type":"process","run":["./mesh-mail","send"]` + if c.user != "" { + raw += `,"user":"` + c.user + `"` + } + raw += `,"bus":{"emits":["mail.send.sent"]}}]}` + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "send") || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming the process and %q", name, err, c.says) + } + } +} + +// 1179/AC3: a bus naming a stream, state or event the module does not declare as its own is refused naming +// it. +func TestAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { + for name, c := range map[string]struct{ bus, says string }{ + "another module's event": {`{"emits":["billing.paid"]}`, "billing.paid"}, + "another module's queue": {`{"queues":["other-queue"]}`, "other-queue"}, + "another module's state": {`{"state":["other-state"]}`, "other-state"}, + } { + _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) + } + } +} + +// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of +// a tool or a seat verb is accepted. +func TestAProcessBusNeverServesOrHoldsASeat(t *testing.T) { + for name, c := range map[string]struct{ bus, says string }{ + "serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"}, + "holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"}, + "claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"}, + "seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"}, + } { + _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) + } + } + accepted := `{"emits":["mail.send.sent","mail.send.failed"],"invokes":["seat:operator-channel.tell","mail.status"]}` + if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil { + t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err) + } +} + +// 1179/AC5: a process declaring bus is composed with no bus key on the wire, and is otherwise byte for byte +// what main composes for the same process without bus. +func TestAProcessesBusNeverReachesTheWire(t *testing.T) { + withBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) + if err != nil { + t.Fatal(err) + } + withoutBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, ""))) + if err != nil { + t.Fatal(err) + } + with := Rendering{Needed: map[string]map[string]string{}} + outWith, err := Resolution{Node: "novox", Modules: []Manifest{withBus}}.Declaration(with) + if err != nil { + t.Fatalf("composing with bus: %v", err) + } + outWithout, err := Resolution{Node: "novox", Modules: []Manifest{withoutBus}}.Declaration(with) + if err != nil { + t.Fatalf("composing without bus: %v", err) + } + var withProcess, withoutProcess map[string]any + for _, r := range outWith { + if r["id"] == "mesh-mail.send" { + withProcess = r + } + } + for _, r := range outWithout { + if r["id"] == "mesh-mail.send" { + withoutProcess = r + } + } + if withProcess == nil || withoutProcess == nil { + t.Fatalf("the process was not composed at all: with=%v without=%v", withProcess, withoutProcess) + } + if _, has := withProcess["bus"]; has { + t.Fatalf("the process resource carried a bus key onto the wire: %v", withProcess) + } + delete(withProcess, "bus") + if !mapsEqual(withProcess, withoutProcess) { + t.Fatalf("with bus declared, the process composed differently: %+v\nwant: %+v", withProcess, withoutProcess) + } +} + +func mapsEqual(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + bv, ok := b[k] + if !ok { + return false + } + if av, ok := v.([]any); ok { + bbv, ok := bv.([]any) + if !ok || len(av) != len(bbv) { + return false + } + for i := range av { + if av[i] != bbv[i] { + return false + } + } + continue + } + if v != bv { + return false + } + } + return true +} diff --git a/go/catalogue/process_env_test.go b/go/catalogue/process_env_test.go new file mode 100644 index 0000000..bbf0347 --- /dev/null +++ b/go/catalogue/process_env_test.go @@ -0,0 +1,80 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// **A process's environment is composed as a container's is** (novox/hq to-be 38 WP4c). +// +// A module's code moving out of its container becomes a process on the machine, and what its +// container's environment asked for — the port this machine gave the module, the place it put the +// module's directory — it still has to be told. Filled for a container and not for a process, the +// literal `${port:8080}` reached the process as its environment and was read as a port; the modules +// that moved first wrote their run-once steps an env file instead. +func processModule(env map[string]any) Manifest { + return Manifest{ + Module: "showcase", + Listens: []Listening{{Port: 8080, From: FromMesh}}, + Resources: []map[string]any{ + {"id": "data", "type": "directory", "mode": "0700"}, + {"id": "setup", "type": "process", "name": "showcase-setup", "run-once": true, + "run": []any{"/usr/bin/showcase", "setup"}, "env": env}, + }, + } +} + +func TestAProcessIsToldItsPortAndItsPlaceInItsEnvironment(t *testing.T) { + env := map[string]any{ + "SHOWCASE_URL": "http://127.0.0.1:${port:8080}", + "SHOWCASE_DATA": "${dir:data}/objects", + "SHOWCASE_DB": "127.0.0.1:${seat:mesh-store:5432}", + "GREETING": "hello", + } + out, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{ + Ports: map[string]map[int]int{"showcase": {8080: 21000}}, + Seats: map[string]map[int]int{"mesh-store": {5432: 6852}}, + }) + if err != nil { + t.Fatalf("a process asking for its port and its place does not compose: %v", err) + } + setup := fileNamed(out, "showcase.setup") + if setup == nil { + t.Fatalf("the process is not in the declaration: %v", out) + } + got, _ := setup["env"].(map[string]any) + for key, want := range map[string]string{ + "SHOWCASE_URL": "http://127.0.0.1:21000", + "SHOWCASE_DATA": "/var/lib/showcase/data/objects", + "SHOWCASE_DB": "127.0.0.1:6852", + "GREETING": "hello", + } { + if got[key] != want { + t.Errorf("the process is told %s=%v, want %q", key, got[key], want) + } + } + if env["SHOWCASE_URL"] != "http://127.0.0.1:${port:8080}" { + t.Fatalf("composing for one machine edited the module's own manifest: %v", env) + } +} + +// An unknown reference in a process's environment is refused as a container's is, naming the +// process and the variable — left alone, it would reach the machine as a literal. +func TestAProcessAskingAboutAnUndeclaredPortIsRefused(t *testing.T) { + env := map[string]any{"SHOWCASE_URL": "http://127.0.0.1:${port:9999}"} + _, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{}) + if err == nil { + t.Fatal("a process was told a port its module never said it listens on") + } + for _, said := range []string{"showcase-setup", "SHOWCASE_URL", "${port:9999}", "8080"} { + if !strings.Contains(err.Error(), said) { + t.Errorf("the refusal does not say %q: %v", said, err) + } + } + + env = map[string]any{"SHOWCASE_DATA": "${dir:date}/objects"} + if _, err := (Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}).Declaration(Rendering{}); err == nil || + !strings.Contains(err.Error(), "${dir:date}") { + t.Fatalf("a process naming no directory of its module was not refused: %v", err) + } +} diff --git a/go/catalogue/reach_test.go b/go/catalogue/reach_test.go new file mode 100644 index 0000000..66b620c --- /dev/null +++ b/go/catalogue/reach_test.go @@ -0,0 +1,206 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// a web module with one routed endpoint, the shape almost every routed module in the catalogue has. +func aRoutedWeb() Manifest { + return Manifest{ + Module: "web", + Listens: []Listening{{Port: 3000, From: FromMesh}}, + Contributes: map[string]map[string]any{ + "route": {"label": "app", "port": 3000}, + }, + } +} + +func reachSet(reach string) SettingsBy { + return SettingsBy{"web": {{From: "node anchor", + Placement: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}} +} + +// namesFor renders the contribution a routed module makes and returns the two names it carries. +func namesFor(t *testing.T, m Manifest, settings SettingsBy) (public, internal string) { + t.Helper() + r := Resolution{Node: "anchor", Modules: []Manifest{m}, + PublicDomain: "example.test", At: "anchor.internal"} + given, err := r.contributions(settings, nil, nil) + if err != nil { + t.Fatalf("contributions: %v", err) + } + for _, c := range given["route"] { + p, _ := c.Values["name"].(string) + i, _ := c.Values["internal-name"].(string) + return p, i + } + t.Fatal("the module contributed no route") + return "", "" +} + +// **Nothing said composes both names, exactly as before.** This is the assertion that keeps every +// mesh already running identical until an assignment speaks, and it is the one that would break first +// if reach were read where it should not be. +func TestAnEndpointWithNoReachKeepsBothNames(t *testing.T) { + public, internal := namesFor(t, aRoutedWeb(), nil) + if public != "app.example.test" || internal != "app.anchor.internal" { + t.Fatalf("names are %q and %q, want both composed as before", public, internal) + } +} + +// An internal endpoint has an internal name and no public one — so the proxy serves it inside, and +// the public authority is never asked for a name nobody wanted. This is what "must not be public" +// could not say before. +func TestAnInternalEndpointHasNoPublicName(t *testing.T) { + public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachInternal)) + if public != "" { + t.Fatalf("an internal endpoint composed the public name %q", public) + } + if internal != "app.anchor.internal" { + t.Fatalf("internal name is %q, want app.anchor.internal", internal) + } +} + +// And the mirror: a public endpoint gets the public name and not the internal one, so the mesh's own +// authority is not asked to certify a name the service is not reached by. +func TestAPublicEndpointHasNoInternalName(t *testing.T) { + public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic)) + if internal != "" { + t.Fatalf("a public endpoint composed the internal name %q", internal) + } + if public != "app.example.test" { + t.Fatalf("public name is %q, want app.example.test", public) + } +} + +func TestBothComposesBothNames(t *testing.T) { + public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachBoth)) + if public == "" || internal == "" { + t.Fatalf("both should compose both names, got %q and %q", public, internal) + } +} + +// **The filter reads the same value — for an endpoint the proxy does not serve.** +// +// A routed endpoint's port is how the proxy reaches it and nothing else (ADR 0045): a public service +// listens from the mesh, only the proxy reaches it, and it is exposed by name. So on a routed +// endpoint the reach asks for a name and the port keeps what the manifest said. +func TestAnUnroutedEndpointsPortFollowsItsReach(t *testing.T) { + // The same module with its route taken away: now the port is the only way in, so reach governs it. + bare := aRoutedWeb() + bare.Contributes = nil + + for _, c := range []struct{ reach, want string }{ + {ReachInternal, FromMesh}, + {ReachPublic, FromEverywhere}, + {ReachBoth, FromEverywhere}, + {ReachMachine, FromMachine}, + } { + r := Resolution{Node: "anchor", Modules: []Manifest{bare}} + rules, err := r.Rules(Rendering{Settings: reachSet(c.reach)}) + if err != nil { + t.Fatalf("%s: rules: %v", c.reach, err) + } + found := false + for _, rule := range rules { + if rule.Port == 3000 { + found = true + if rule.From != c.want { + t.Fatalf("reach %q made the filter say %q, want %q", c.reach, rule.From, c.want) + } + } + } + if !found { + t.Fatalf("reach %q produced no rule for the port", c.reach) + } + } +} + +// **A public name does not open the machine's port**, which is the case that found this. +// +// A module whose routed name must be public and whose machine-side port must not be had no way to say +// so while one value drove both. Under one value it could not be expressed; the port would reopen. +func TestAPublicNameLeavesARoutedPortAsTheManifestSaid(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{aRoutedWeb()}, + PublicDomain: "example.test", At: "anchor.internal"} + rules, err := r.Rules(Rendering{Settings: reachSet(ReachPublic)}) + if err != nil { + t.Fatal(err) + } + for _, rule := range rules { + if rule.Port == 3000 && rule.From != FromMesh { + t.Fatalf("a public reach opened a routed port to %q; the proxy is how it is reached", + rule.From) + } + } + // And the name it asked for is there, so the reach was not simply ignored. + public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic)) + if public != "app.example.test" || internal != "" { + t.Fatalf("names are %q and %q, want the public one only", public, internal) + } +} + +// A reach for a port the module does not listen on reaches nothing, and is refused where it is +// written rather than accepted and ignored. +func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) { + _, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", + Placement: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}}) + if err == nil || !strings.Contains(err.Error(), "reaches nothing") { + t.Fatalf("a reach naming an undeclared port was accepted: %v", err) + } +} + +// A value that is not a reach is refused, and the refusal names the four so a reader is one edit from +// right. "mesh" is the tempting wrong answer, because that is the filter's word for nearly the same +// thing. +func TestAValueThatIsNotAReachIsRefused(t *testing.T) { + for _, wrong := range []string{"mesh", "anywhere", "private", "true"} { + _, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", + Placement: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}}) + if err == nil || !strings.Contains(err.Error(), "a reach is") { + t.Fatalf("%q was accepted as a reach: %v", wrong, err) + } + } +} + +// **A port that says both reach and expose is refused.** They say the same thing in different words, +// and accepting both would have the filter follow one while the names followed the other — the +// disagreement ADR 0138 exists to remove, reintroduced by the migration away from the older word. +func TestReachAndExposeForOnePortAreRefused(t *testing.T) { + _, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Placement: map[string]any{ + ReachSetting: map[string]any{"3000": ReachInternal}, + ExposeSetting: map[string]any{"3000": FromEverywhere}, + }}}) + if err == nil || !strings.Contains(err.Error(), "same thing in different") { + t.Fatalf("a port set both ways was accepted: %v", err) + } +} + +// A path-level refusal carries no port: it is a rule about a name, not an endpoint, and it inherits +// whatever that name turned out to be. Narrowing the endpoint must not silently drop it. +func TestARuleWithNoPortIsLeftAlone(t *testing.T) { + m := aRoutedWeb() + m.ContributesMany = map[string]map[string]map[string]any{ + "route": {"refused": {"label": "app", "path": "/internal", "deny": true}}, + } + r := Resolution{Node: "anchor", Modules: []Manifest{m}, + PublicDomain: "example.test", At: "anchor.internal"} + given, err := r.contributions(reachSet(ReachInternal), nil, nil) + if err != nil { + t.Fatal(err) + } + var sawDeny bool + for _, c := range given["route"] { + if deny, _ := c.Values["deny"].(bool); deny { + sawDeny = true + // It keeps both, because it named no endpoint to be narrowed by. + if c.Values["name"] == nil || c.Values["internal-name"] == nil { + t.Fatalf("the path rule lost a name it shadows: %v", c.Values) + } + } + } + if !sawDeny { + t.Fatal("the path rule was dropped") + } +} diff --git a/go/catalogue/received_on_the_bus_test.go b/go/catalogue/received_on_the_bus_test.go new file mode 100644 index 0000000..185b10b --- /dev/null +++ b/go/catalogue/received_on_the_bus_test.go @@ -0,0 +1,66 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "testing" +) + +// What a provider receives is composed once, and issued twice: as its received file, and in its +// membership on the bus (novox/hq ADR 0167). The two are the same list, so a proxy reading the bus +// and one reading the file serve the same routes — including the port the machine published, which +// is the same-node fix the file already carries. +func TestWhatAProviderReceivesIsTheSameOnTheBusAsInItsFile(t *testing.T) { + gitea := Manifest{ + Module: "gitea", Version: "1", + Listens: []Listening{{Port: 3000, Protocol: "tcp", From: FromMesh}}, + Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"}, + }}, + } + r, err := Resolve(shelf(gitea, routeProxy(), stepCA()), + []string{"gitea", "route-proxy", "step-ca"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + composed, err := r.Compose(Rendering{Ports: map[string]map[int]int{"gitea": {3000: 20000}}}) + if err != nil { + t.Fatal(err) + } + + file := fileNamed(composed.Resources, "route-proxy.received-route") + if file == nil { + t.Fatal("the proxy was given no routes file") + } + var written struct { + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(file["content"].(string)), &written); err != nil { + t.Fatal(err) + } + issued, said := composed.Received["route-proxy"]["route"] + if !said { + t.Fatalf("nothing is issued for the proxy to receive on the bus: %v", composed.Received) + } + // Compared as JSON, which is what both are once they leave the controller. + a, _ := json.Marshal(written.Given) + b, _ := json.Marshal(issued) + var fromFile, fromBus any + _ = json.Unmarshal(a, &fromFile) + _ = json.Unmarshal(b, &fromBus) + if !reflect.DeepEqual(fromFile, fromBus) { + t.Errorf("the bus and the file disagree about the routes:\nfile %s\nbus %s", a, b) + } + if len(issued) != 1 { + t.Fatalf("expected one route on the bus, got %v", issued) + } + if port, ok := asPort(issued[0].Values["port"]); !ok || port != 20000 { + t.Errorf("the bus carries a port nothing listens on: %v", issued[0].Values["port"]) + } + + // A module that receives nothing is issued nothing to receive. + if _, any := composed.Received["gitea"]; any { + t.Errorf("a module that receives nothing was issued something: %v", composed.Received["gitea"]) + } +} diff --git a/go/catalogue/reflects_renamed_test.go b/go/catalogue/reflects_renamed_test.go new file mode 100644 index 0000000..e5bee0b --- /dev/null +++ b/go/catalogue/reflects_renamed_test.go @@ -0,0 +1,32 @@ +package catalogue + +import ( + "reflect" + "testing" +) + +func TestReflectsRenamedReadsBothShapes(t *testing.T) { + // The list arrives as []any when the resource was parsed from JSON, and as []string when it + // was composed in code — the overlay's registry trust is the second kind. A shape that was + // skipped would leave the reference unprefixed, pointing at nothing, and the service would + // never restart while every check passed (novox/hq issues 042/048, the run-8 diagnosis). + parsed := reflectsRenamed("mesh-wireguard", []any{"registry-trust"}) + if !reflect.DeepEqual(parsed, []any{"mesh-wireguard.registry-trust"}) { + t.Fatalf("parsed shape: %v", parsed) + } + composed := reflectsRenamed("mesh-wireguard", []string{"registry-trust"}) + if !reflect.DeepEqual(composed, []any{"mesh-wireguard.registry-trust"}) { + t.Fatalf("composed shape: %v", composed) + } + + // A name already under a module keeps it: that is how a service reflects a file another + // module put on the machine. + kept := reflectsRenamed("resolver", []string{"mesh-wireguard.fact-node-names", "own-config"}) + if !reflect.DeepEqual(kept, []any{"mesh-wireguard.fact-node-names", "resolver.own-config"}) { + t.Fatalf("dotted name was not kept: %v", kept) + } + + if got := reflectsRenamed("any", nil); got != nil { + t.Fatalf("no list should rename to nothing, got %v", got) + } +} diff --git a/go/catalogue/replaces_test.go b/go/catalogue/replaces_test.go new file mode 100644 index 0000000..4b34320 --- /dev/null +++ b/go/catalogue/replaces_test.go @@ -0,0 +1,119 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The verbs an agent works around most say what they replace (novox/hq ADR 0245): the journal, a +// restart, a unit's status, the hosts file — each the command the agent would otherwise type over ssh. +func TestTheVerbsWorkedAroundSayWhatTheyReplace(t *testing.T) { + want := map[string]string{ + ServiceManagerSeat + ".journal": "journalctl", + ServiceManagerSeat + ".restart": "systemctl restart", + ServiceManagerSeat + ".status": "systemctl status", + "node-hostname.add": "edit /etc/hosts", + ControllerSeatName + ".node": "hostnamectl", + } + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + cmd, ok := want[s.Name+"."+v.Name] + if !ok { + continue + } + delete(want, s.Name+"."+v.Name) + found := false + for _, r := range v.Replaces { + found = found || r == cmd + } + if !found { + t.Errorf("%s.%s does not say it replaces %q: %v", s.Name, v.Name, cmd, v.Replaces) + } + } + } + for verb := range want { + t.Errorf("%s is not a verb of the compiled seats", verb) + } +} + +// Every command a seat's verb replaces is one short line, said once — what the guard and the search match. +func TestEveryReplacedCommandIsOneShortLine(t *testing.T) { + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + seen := map[string]bool{} + for _, r := range v.Replaces { + if strings.TrimSpace(r) == "" || strings.ContainsAny(r, "\n\r") || len(r) > MaxReplaced || seen[r] { + t.Errorf("%s.%s replaces %q, which is not one short line said once", s.Name, v.Name, r) + } + seen[r] = true + } + } + } +} + +// A verb's definition in a manifest may say what it replaces, like the mesh's own. +func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["refund"],` + + `"seats":[{"name":"shop-till","serves":[{"name":"refund","replaces":["psql -c refund"]}]}],` + + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) + if err != nil { + t.Fatal(err) + } + if got := m.DefinesSeats[0].Serves[0].Replaces; len(got) != 1 || got[0] != "psql -c refund" { + t.Fatalf("replaces not read: %v", got) + } +} + +// A module says what its own tools replace, and only for a tool it declares. +func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + + `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) + if err != nil { + t.Fatal(err) + } + if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { + t.Fatalf("replaces not read: %v", m.Replaces) + } + for _, c := range []struct{ manifest, says string }{ + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, + "declares no such tool"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, + "more than one line"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, + "twice"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", MaxReplaced+1) + `"]}}`, + "longer than"}, + } { + if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) + } + } +} + +// A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled +// one, so the `tools` answer says it on a mesh whose rows exist. +func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) { + was := Seats() + t.Cleanup(func() { UseSeats(was) }) + var row Seat + for _, s := range DefaultSeats() { + if s.Name == ServiceManagerSeat { + row = s + } + } + stored := make([]Verb, len(row.Serves)) + for i, v := range row.Serves { + v.Replaces = nil + stored[i] = v + } + row.Serves = stored + UseSeats([]Seat{row}) + got, _ := SeatNamed(ServiceManagerSeat) + for _, v := range got.Serves { + if v.Name == "journal" && (len(v.Replaces) == 0 || v.Replaces[0] != "journalctl") { + t.Fatalf("the seeded journal verb says it replaces %v", v.Replaces) + } + } +} diff --git a/go/catalogue/replay166_test.go b/go/catalogue/replay166_test.go new file mode 100644 index 0000000..7611666 --- /dev/null +++ b/go/catalogue/replay166_test.go @@ -0,0 +1,44 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Replay of novox/hq issue 166: every soft dependency had become a hard requirement. grafana runs without a +// data source, yet a manifest could not say so: a mesh without influxdb could not register a grafana that +// said its influxdb-api was optional, and with influxdb only in the catalogue, assigning grafana installed +// influxdb for it. Written only with what the catalogue had before its fix (mesh-controller #281): +// ParseManifest, Resolve, and the resolve tests' shelf, reachable machine and names, so it is laid over the +// commit before. +func TestReplay166(t *testing.T) { + grafana, err := ParseManifest([]byte(`{"module":"grafana","version":"1", + "requires":[{"name":"influxdb-api","optional":true}]}`)) + if err != nil { + t.Fatalf("a manifest cannot say grafana runs without influxdb-api: %v", err) + } + catalogue := shelf( + Manifest{Module: "influxdb", Version: "1", Provides: FromAnywhere("influxdb-api")}, + grafana, + ) + // Nothing in the mesh runs influxdb; it is only in the catalogue. + got, err := Resolve(catalogue, []string{"grafana"}, reachable(), World{}) + if err != nil { + t.Fatalf("grafana could not be assigned on a mesh without influxdb: %v", err) + } + if have := strings.Join(names(got), " "); have != "grafana" { + t.Fatalf("assigning grafana put %s on the machine; nothing is installed for an optional requirement", have) + } + if len(got.Needs) != 0 { + t.Fatalf("grafana was given a binding with nothing providing it: %+v", got.Needs) + } + // And once influxdb runs somewhere, grafana binds to it as before. + got, err = Resolve(catalogue, []string{"grafana"}, reachable(), World{Offered: map[string][]Provider{ + "influxdb-api": {{Node: "anchor", At: "anchor.internal", Module: "influxdb"}}}}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "anchor" || got.Needs[0].For != "grafana" { + t.Fatalf("grafana did not bind to influxdb on anchor: %+v", got.Needs) + } +} diff --git a/go/catalogue/replay279_test.go b/go/catalogue/replay279_test.go new file mode 100644 index 0000000..c350a4b --- /dev/null +++ b/go/catalogue/replay279_test.go @@ -0,0 +1,36 @@ +package catalogue + +import "testing" + +// Replay of novox/hq issue 279 (2026-10-06): the media managers' recycle-bin folders were declared owned by +// a literal user and group, and on a machine whose puid and pgid settings named another account the apps +// refused them as not writable. A directory's owner could not follow the account its module runs as. Written +// only with what the catalogue had before its fix (mesh-controller #260): Compose, LeftOut, a setting layer, +// and the adoption tests' anchor, so it is laid over the commit before. +func TestReplay279(t *testing.T) { + r := anAdoptedAnchor() + r.Modules = append(r.Modules, Manifest{Module: "sonarr", + Resources: []map[string]any{ + {"id": "recycle-bin", "type": "directory", "path": "/srv/sonarr/recycle", "mode": "0755", + "owner": "${setting:puid}:${setting:pgid}"}, + {"id": "identity", "type": "file", "path": "/srv/sonarr/identity.env", "mode": "0644", + "content": "PUID=${setting:puid}\nPGID=${setting:pgid}\n"}, + }, + }) + with := anchorRendering(false) + with.Settings["sonarr"] = []Layer{{From: "node anchor", Values: map[string]any{"puid": "1001", "pgid": "2000"}}} + composed, err := r.Compose(with) + if err != nil { + t.Fatalf("the machine did not compose: %v", err) + } + if why, left := composed.LeftOut["sonarr"]; left { + t.Fatalf("the module was left out: %s", why) + } + got, ok := byID(composed.Resources)["sonarr.recycle-bin"] + if !ok { + t.Fatal("the recycle bin was not declared") + } + if got["owner"] != "1001:2000" { + t.Fatalf("the recycle bin reaches the node-engine owned by %v, not the account the module runs as (1001:2000)", got["owner"]) + } +} diff --git a/go/catalogue/replay412_test.go b/go/catalogue/replay412_test.go new file mode 100644 index 0000000..178667d --- /dev/null +++ b/go/catalogue/replay412_test.go @@ -0,0 +1,36 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Replay of novox/hq issue 412 (2026-10-10): which module owns /etc/hosts was enforced only after the fact, +// when two modules on one machine declared it; a module that did not claim node-hostname could declare +// /etc/hosts, and a module could claim node-hostname declaring none of its paths, and nothing refused either. +// Written only with what the catalogue had before its fix (mesh-controller #267): CatalogueProblems, Shelf, +// SeatNamed and CanHold, so it is laid over the commit before. +func TestReplay412(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, "node-hostname") { + t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) + } + + seat, ok := SeatNamed("node-hostname") + if !ok { + t.Fatal("no node-hostname seat") + } + holder := Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, + Serves: []string{"entries", "add", "remove"}}}} + err := CanHold(holder, seat) + if err == nil { + t.Fatal("a holder of node-hostname declaring neither /etc/hosts nor /etc/hostname was accepted") + } + for _, want := range []string{"/etc/hosts", "/etc/hostname"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} diff --git a/go/catalogue/replays_test.go b/go/catalogue/replays_test.go new file mode 100644 index 0000000..3439da3 --- /dev/null +++ b/go/catalogue/replays_test.go @@ -0,0 +1,36 @@ +package catalogue + +import ( + "slices" + "testing" +) + +// **R298 — a seat's new verb lands in either repository first** (novox/hq issue 298). On 2026-10-07 the +// delivery seat gained `checks`: the seat is defined here and held by a module in the catalogue, and +// neither could merge first — a controller promising the verb refused the holder that did not serve it +// yet, and a holder serving it was refused by the controller that did not promise it. The outcome +// asserted: the holder as it was, and the holder serving `checks` too, both hold the delivery seat. +// +// Written only with what the catalogue package had before the fix (SeatNamed, VerbNames, CanHold), so the +// prover can lay it over that commit. +func TestReplay298ADeliverySeatHolderHoldsBeforeAndAfterItServesChecks(t *testing.T) { + seat, ok := SeatNamed(DeliverySeat) + if !ok { + t.Fatal("the delivery seat is not in the set") + } + var before []string + for _, v := range VerbNames(seat.Serves) { + if v != "checks" { + before = append(before, v) + } + } + holder := func(serves []string) Manifest { + return Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: serves}}} + } + if err := CanHold(holder(before), seat); err != nil { + t.Fatalf("the holder as the catalogue had it, before it serves checks, is refused: %v", err) + } + if err := CanHold(holder(append(slices.Clone(before), "checks")), seat); err != nil { + t.Fatalf("the holder serving checks is refused — the catalogue cannot merge first: %v", err) + } +} diff --git a/go/catalogue/resolve_keeps_references_test.go b/go/catalogue/resolve_keeps_references_test.go new file mode 100644 index 0000000..24eb07c --- /dev/null +++ b/go/catalogue/resolve_keeps_references_test.go @@ -0,0 +1,42 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A resolved manifest keeps a built artifact's reference in the store-relative form, never the +// address the builder reached the store by (novox/hq ADR 0155): on 2026-10-02 the first bundle +// resolved on the mesh carried the store's host in bundles[0].source and registration refused it +// as naming an installation. Archive resources are the same kind of reference and get the same. +func TestAResolvedReferenceIsKeptNotRouted(t *testing.T) { + m, err := ParseManifest([]byte(`{ + "module": "sample", "version": "1", + "tools": ["one"], + "build": {"artifacts": [ + {"name": "code", "kind": "bundle", "language": "typescript", "entrypoints": ["tools/index.js"]}, + {"name": "files", "kind": "archive", "from": "files"} + ]}, + "resources": [{"id": "packed", "type": "archive", "path": "/opt/sample", "artifact": "files"}] + }`)) + if err != nil { + t.Fatal(err) + } + digest := "sha256:" + strings.Repeat("ab", 32) + resolved, err := m.Resolve([]Built{ + {Name: "code", Kind: ArtifactBundle, Reference: "http://store.example:5100/v2/sample/code/blobs/" + digest, Digest: digest}, + {Name: "files", Kind: ArtifactArchive, Reference: "http://store.example:5100/v2/sample/files/blobs/" + digest, Digest: digest}, + }) + if err != nil { + t.Fatal(err) + } + if got, want := resolved.Bundles[0].Source, ArtifactStoreScheme+"sample/code/blobs/"+digest; got != want { + t.Errorf("bundle source %q, want the kept form %q", got, want) + } + if got, want := resolved.Resources[0]["source"], ArtifactStoreScheme+"sample/files/blobs/"+digest; got != want { + t.Errorf("archive source %q, want the kept form %q", got, want) + } + if problems := InstallationProblems(resolved); len(problems) != 0 { + t.Errorf("a resolved manifest names an installation: %v", problems) + } +} diff --git a/go/catalogue/resolve_loopback_test.go b/go/catalogue/resolve_loopback_test.go new file mode 100644 index 0000000..d9ca22c --- /dev/null +++ b/go/catalogue/resolve_loopback_test.go @@ -0,0 +1,52 @@ +package catalogue + +import "testing" + +// A MESH-SCOPED provider on the consumer's own node must deliver a usable address too. +// +// The sibling case — a node-scoped provider minting no credential — has had this fallback and a +// test for it for some time. This branch did not, and the difference is invisible until something +// puts the address into a string: the mesh's own artifact store is mesh-scoped and lives on the +// same machine as the builder that pushes to it, so the builder's environment was written as +// `MESH_REGISTRY=:5000`. Nothing refused it. The runtime did, several steps later, with +// `":5000/mesh-tools/build" is not a valid repository/tag` — a message about a tag, for a fault in +// how a binding was resolved. +// +// A machine off the private network still reaches itself. +func TestAMeshScopedProviderOnThisNodeStillCarriesAnAddress(t *testing.T) { + provider := Manifest{Module: "registry", + Provides: []Offer{{Name: "artifact-store", Scope: ScopeMesh}}, + Serves: map[string]map[string]any{ + "artifact-store": {"port": 5000}}} + consumer := Manifest{Module: "builder", Requires: []string{"artifact-store"}} + + // No `At`: a mesh with no private network raised, which is every mesh before somebody places + // its nodes on one — including the moment just after it is installed. + got, err := Resolve( + map[string]Manifest{"registry": provider, "builder": consumer}, + []string{"builder", "registry"}, + Node{Name: "anchor"}, + World{}) + if err != nil { + t.Fatalf("a mesh-scoped provision answered on this very node was refused: %v", err) + } + + var found *Needed + for i := range got.Needs { + if got.Needs[i].Name == "artifact-store" && got.Needs[i].For == "builder" { + found = &got.Needs[i] + } + } + if found == nil { + t.Fatalf("the store was not delivered to the builder at all: %+v", got.Needs) + } + if found.At == "" { + t.Fatalf("the binding carries no address, so anything composing a host from it gets none: %+v", found) + } + if found.At != "127.0.0.1" { + t.Fatalf("off the private network the address must fall back to loopback, got %q", found.At) + } + if got, want := plainly(found.Serves["port"]), "5000"; got != want { + t.Fatalf("the port was not delivered: got %q want %q", got, want) + } +} diff --git a/go/catalogue/resolve_test.go b/go/catalogue/resolve_test.go new file mode 100644 index 0000000..54b1e10 --- /dev/null +++ b/go/catalogue/resolve_test.go @@ -0,0 +1,509 @@ +package catalogue + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func mod(name string, provides, requires, capabilities []string, claims ...Claim) Manifest { + return Manifest{Module: name, Provides: Offers(provides...), Requires: requires, + Capabilities: capabilities, Claims: claims} +} + +func shelf(ms ...Manifest) map[string]Manifest { + out := map[string]Manifest{} + for _, m := range ms { + out[m.Module] = m + } + return out +} + +func workstation() Node { + return Node{Name: "workstation", Site: "house", + Capabilities: map[string]bool{"seat": true, "container-runtime": true}} +} + +func names(r Resolution) []string { + var out []string + for _, m := range r.Modules { + out = append(out, m.Module) + } + return out +} + +func TestARequirementWithOneAnswerIsTakenSilently(t *testing.T) { + // `install i3` should bring in xorg without asking anybody anything, because there was no + // choice to make. This is what keeps the refusing rule from being tiresome. + got, err := Resolve(shelf( + mod("i3", nil, []string{"xorg"}, []string{"seat"}), + mod("xorg", []string{"display-server"}, nil, []string{"seat"}, Claim{Name: "the-seat"}), + ), []string{"i3"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + if len(got.Modules) != 2 { + t.Fatalf("resolved %v; i3 should have brought xorg with it", names(got)) + } + if got.Because["xorg"] == "assigned" { + t.Error("xorg is recorded as assigned; it was required") + } +} + +func TestARequirementWithSeveralAnswersIsRefusedAndNamed(t *testing.T) { + // The mesh does not pick. A default would be a choice made for somebody who finds out later, + // and naming the candidates is the whole remedy. + _, err := Resolve(shelf( + mod("editor", nil, []string{"shell"}, nil), + mod("bash", []string{"shell"}, nil, nil), + mod("zsh", []string{"shell"}, nil, nil), + mod("fish", []string{"shell"}, nil, nil), + ), []string{"editor"}, workstation(), World{}) + + if err == nil { + t.Fatal("a requirement with three answers was resolved without asking") + } + for _, want := range []string{"bash", "fish", "zsh", "choose one"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } + } +} + +func TestARequirementWithNoAnswerIsRefused(t *testing.T) { + _, err := Resolve(shelf(mod("i3", nil, []string{"xorg"}, nil)), []string{"i3"}, workstation(), World{}) + + if err == nil || !strings.Contains(err.Error(), "nothing provides") { + t.Fatalf("a requirement nothing satisfies gave %v", err) + } +} + +func TestSeveralModulesMayProvideTheSameThingAndCoexist(t *testing.T) { + // Shells. Nothing is claimed, so any number may be assigned — which is the case that made + // "flavor" look necessary and turns out to need nothing at all. + got, err := Resolve(shelf( + mod("bash", []string{"shell"}, nil, nil), + mod("zsh", []string{"shell"}, nil, nil), + mod("fish", []string{"shell"}, nil, nil), + ), []string{"bash", "zsh", "fish"}, workstation(), World{}) + + if err != nil { + t.Fatalf("three shells could not coexist: %v", err) + } + if len(got.Modules) != 3 { + t.Errorf("resolved %v", names(got)) + } +} + +func TestTwoModulesClaimingOneThingAreRefused(t *testing.T) { + // xorg and wayland. Neither knows the other exists — the refusal comes from both claiming + // the seat, which is what lets a third display server be added without editing either. + _, err := Resolve(shelf( + mod("xorg", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), + mod("wayland", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), + ), []string{"xorg", "wayland"}, workstation(), World{}) + + if err == nil { + t.Fatal("two modules claiming the seat were both assigned") + } + if !strings.Contains(err.Error(), "the-seat") || !strings.Contains(err.Error(), "per node") { + t.Errorf("the refusal does not say what was claimed or how widely: %v", err) + } +} + +func TestAThirdModuleNeedsNoChangeToTheOthers(t *testing.T) { + // The property claims exist for. A third display server says what it claims and nothing else + // in the catalogue is touched — where pairwise exclusion would need xorg and wayland edited + // to know about it, and the edits would grow as the square of the count. + catalogue := shelf( + mod("xorg", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), + mod("wayland", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), + mod("mir", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), + ) + for _, pair := range [][]string{{"xorg", "mir"}, {"wayland", "mir"}} { + if _, err := Resolve(catalogue, pair, workstation(), World{}); err == nil { + t.Errorf("%v were both assigned", pair) + } + } + if _, err := Resolve(catalogue, []string{"mir"}, workstation(), World{}); err != nil { + t.Errorf("the newcomer alone was refused: %v", err) + } +} + +func TestADirectlyAssignedModuleTheMachineCannotHostIsReportedNotRefused(t *testing.T) { + // A person put a display server on a seatless machine. The remedy differs from a missing module + // and the message has to say which — nothing can be installed to give a server a seat. But it is + // one module on the wrong machine, not a reason the whole node fails to resolve: it is kept out + // of what the node runs and reported as un-applied, so the healthy modules beside it still + // converge. + server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} + got, err := Resolve(shelf(mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"})), []string{"xorg"}, server, World{}) + + if err != nil { + t.Fatalf("one un-hostable assignment refused the whole node: %v", err) + } + if len(got.Modules) != 0 { + t.Errorf("xorg was declared on a machine that cannot run it: %v", names(got)) + } + if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "xorg" { + t.Fatalf("xorg was not reported as un-applied: %+v", got.Unhostable) + } + if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "seat" { + t.Errorf("the missing capability was not named: %+v", got.Unhostable[0]) + } + if !strings.Contains(WrongMachine("xorg", "seat", "server"), "wrong machine") { + t.Errorf("the report reads like a missing module") + } +} + +func TestAnUnhostableModuleSomethingRequiresRefusesTheNode(t *testing.T) { + // The other side of the distinction. A module the machine cannot host that is *required* by + // something running here makes the set incoherent: the requiring module cannot have its + // requirement met on this machine, so it is refused rather than quietly declared without it. + server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} + _, err := Resolve(shelf( + mod("desktop", nil, []string{"display-server"}, nil), + mod("xorg", []string{"display-server"}, nil, []string{"seat"}), + ), []string{"desktop"}, server, World{}) + + if err == nil { + t.Fatal("a node requiring a module the machine cannot host was resolved") + } + if !strings.Contains(err.Error(), "wrong machine") { + t.Errorf("the refusal reads like a missing module: %v", err) + } +} + +func TestOneUnhostableAssignmentDoesNotTakeDownTheHealthyModules(t *testing.T) { + // The bug the whole-mesh dry-run found: fail2ban declares a capability the host lacks, and its + // one un-hostable assignment refused the entire node's resolution — so a push refused to send + // the healthy modules beside it too. The healthy modules must still resolve and converge; the + // un-hostable one is reported as un-applied, neither silently dropped nor fatal to the rest. + server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} + got, err := Resolve(shelf( + mod("web", nil, nil, nil), + mod("cache", nil, nil, nil), + mod("cron", nil, nil, nil), + // The one on the wrong machine: it needs a firewall the machine's profile does not report. + mod("fail2ban", nil, nil, []string{"firewall"}), + ), []string{"web", "cache", "cron", "fail2ban"}, server, World{}) + + if err != nil { + t.Fatalf("one un-hostable assignment refused the whole node: %v", err) + } + // The healthy three resolved. + if got := names(got); len(got) != 3 { + t.Fatalf("the healthy modules did not all resolve: %v", got) + } + for _, m := range got.Modules { + if m.Module == "fail2ban" { + t.Error("fail2ban was declared on a machine that cannot run it") + } + } + // The un-hostable one is reported, not silently dropped. + if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "fail2ban" { + t.Fatalf("fail2ban was not reported as un-applied: %+v", got.Unhostable) + } + if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "firewall" { + t.Errorf("the missing capability was not named: %+v", got.Unhostable[0]) + } +} + +func TestAMeshWideClaimIsHeldByOneNode(t *testing.T) { + // The hub, said as a claim rather than hard-coded. Another node already holds it, so this one + // cannot. + _, err := Resolve(shelf(mod("hub", nil, nil, nil, Claim{Name: "the-hub", Scope: ScopeMesh})), + []string{"hub"}, workstation(), + World{Held: []Held{{Claim: "the-hub", Scope: ScopeMesh, Node: "anchor", Module: "hub"}}}) + if err == nil { + t.Fatal("two nodes both hold a mesh-wide claim") + } + if !strings.Contains(err.Error(), "anchor") || !strings.Contains(err.Error(), "one per mesh") { + t.Errorf("the refusal does not say who holds it: %v", err) + } +} + +func TestAFoundationModuleCannotBeRaisedOnASecondNode(t *testing.T) { + // novox/hq 04-ISSUES/056. The store and broker are adopted in place on the control-node; each + // foundation module claims a mesh-scoped seat named after its server — the same mechanism that + // keeps one controller — so a second `assign` to another node is refused rather than silently + // raising a second postgres or broker that holds none of the first's data. + for _, tc := range []struct{ module, seat string }{ + {"postgres", "mesh-store"}, + {"lavinmq", "mesh-broker"}, + {"mesh-controller", "mesh-controller"}, + } { + _, err := Resolve( + shelf(mod(tc.module, nil, nil, nil, Claim{Name: tc.seat, Scope: ScopeMesh})), + []string{tc.module}, workstation(), + World{Held: []Held{{Claim: tc.seat, Scope: ScopeMesh, Node: "anchor", Module: tc.module}}}) + if err == nil { + t.Fatalf("%s was raised on a second node though %s is a mesh-wide seat", tc.module, tc.seat) + } + if !strings.Contains(err.Error(), "one per mesh") { + t.Errorf("%s: the refusal does not say it is one per mesh: %v", tc.module, err) + } + } +} + +func TestASiteClaimOnlyCollidesWithinThatSite(t *testing.T) { + // A DHCP server per segment. Two of them is a fault at one site and perfectly ordinary + // across two, and treating site as mesh would forbid the ordinary case. + catalogue := shelf(mod("dhcp", nil, nil, nil, Claim{Name: "dhcp", Scope: ScopeSite})) + + elsewhere := []Held{{Claim: "dhcp", Scope: ScopeSite, Node: "other", Module: "dhcp", Site: "house"}} + if _, err := Resolve(catalogue, []string{"dhcp"}, workstation(), World{Held: elsewhere}); err == nil { + t.Error("two DHCP servers at one site were allowed") + } + + faraway := []Held{{Claim: "dhcp", Scope: ScopeSite, Node: "other", Module: "dhcp", Site: "office"}} + if _, err := Resolve(catalogue, []string{"dhcp"}, workstation(), World{Held: faraway}); err != nil { + t.Errorf("a DHCP server at another site was treated as a collision: %v", err) + } +} + +func TestTwoModulesWritingOneFileAreRefusedWithoutAnyClaim(t *testing.T) { + // This conflict costs no manifest field: the mesh already holds every resource of every + // module, so two declaring one path are visible without either knowing the other exists. + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/thing.conf"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/thing.conf"}} + + _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) + if err == nil { + t.Fatal("two modules writing the same file were both assigned") + } + if !strings.Contains(err.Error(), "/etc/thing.conf") { + t.Errorf("the refusal does not name the file: %v", err) + } +} + +func TestResourceIdentitiesCarryTheirModule(t *testing.T) { + // Two modules may reasonably both call something "config". Without the prefix the second + // would silently replace the first, and the node would apply one of them and report success. + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{{"id": "config", "type": "file", "path": "/etc/a"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{{"id": "config", "type": "file", "path": "/etc/b"}} + + got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, r := range mustDeclare(t, got) { + id := r["id"].(string) + if seen[id] { + t.Errorf("two resources share the identity %q", id) + } + seen[id] = true + } + if !seen["a.config"] || !seen["b.config"] { + t.Errorf("identities are not qualified by module: %v", seen) + } +} + +func TestWhatAServiceReflectsIsQualifiedToo(t *testing.T) { + // Otherwise it names a resource that no longer exists under that identity, and the service + // quietly stops being restarted when its own configuration changes. + m := mod("thing", nil, nil, nil) + m.Resources = []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/thing.conf"}, + {"id": "svc", "type": "service", "unit": "thing.service", "state": "running", + "restart-on": []any{"conf"}}, + } + got, err := Resolve(shelf(m), []string{"thing"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + for _, r := range mustDeclare(t, got) { + if r["id"] == "thing.svc" { + if got := fmt.Sprint(r["restart-on"]); got != "[thing.conf]" { + t.Errorf("a service reflects %s, which is not a resource in the declaration", got) + } + return + } + } + t.Error("the service is missing from the declaration") +} + +func TestEveryReasonIsGivenAtOnce(t *testing.T) { + // Somebody resolving these fixes them in one pass or in three. Every reason that genuinely + // refuses the set is collected, rather than only the first: a claim two modules both take, a + // requirement nothing answers, and a requirement several answer without anybody choosing. + _, err := Resolve(shelf( + mod("xorg", nil, nil, nil, Claim{Name: "the-seat"}), + mod("wayland", nil, nil, nil, Claim{Name: "the-seat"}), + mod("i3", nil, []string{"compositor"}, nil), + mod("editor", nil, []string{"shell"}, nil), + mod("bash", []string{"shell"}, nil, nil), + mod("zsh", []string{"shell"}, nil, nil), + ), []string{"xorg", "wayland", "i3", "editor"}, workstation(), World{}) + + if err == nil { + t.Fatal("expected refusals") + } + if strings.Count(err.Error(), "\n - ") < 3 { + t.Errorf("only some problems were reported:\n%v", err) + } +} + +func TestACycleStopsRatherThanRunsAway(t *testing.T) { + // Two modules requiring each other is a mistake somebody makes, and it must produce an answer + // rather than a stack overflow. + got, err := Resolve(shelf( + mod("a", nil, []string{"b"}, nil), + mod("b", nil, []string{"a"}, nil), + ), []string{"a"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + if len(got.Modules) != 2 { + t.Errorf("a cycle resolved to %v", names(got)) + } +} + +func TestChoosingOneSatisfiesTheRequirement(t *testing.T) { + // The other half of refusing. "Choose one and assign it" has to actually work, or the remedy + // names three modules and then ignores the one you pick — which is how this read the first + // time it was used on a real mesh. + got, err := Resolve(shelf( + mod("editor", nil, []string{"shell"}, nil), + mod("bash", []string{"shell"}, nil, nil), + mod("zsh", []string{"shell"}, nil, nil), + mod("fish", []string{"shell"}, nil, nil), + ), []string{"editor", "zsh"}, workstation(), World{}) + + if err != nil { + t.Fatalf("choosing a shell did not satisfy the requirement for one: %v", err) + } + if len(got.Modules) != 2 { + t.Errorf("resolved %v; only the chosen shell should have come in", names(got)) + } +} + +func TestChoosingSeveralIsStillFine(t *testing.T) { + // And the choice is not exclusive. Nothing is claimed, so a person may have all three and + // the requirement is answered by whichever they picked. + got, err := Resolve(shelf( + mod("editor", nil, []string{"shell"}, nil), + mod("bash", []string{"shell"}, nil, nil), + mod("zsh", []string{"shell"}, nil, nil), + mod("fish", []string{"shell"}, nil, nil), + ), []string{"editor", "zsh", "bash", "fish"}, workstation(), World{}) + + if err != nil { + t.Fatal(err) + } + if len(got.Modules) != 4 { + t.Errorf("resolved %v", names(got)) + } +} + +func TestARequirementNamingAModuleMeansThatModule(t *testing.T) { + // i3 requires xorg and means xorg, not "anything calling itself a display server". Otherwise + // assigning wayland would silently satisfy i3 and the machine would come up with a window + // manager talking to nothing. + _, err := Resolve(shelf( + mod("i3", nil, []string{"xorg"}, nil), + mod("xorg", []string{"display-server"}, nil, nil), + mod("wayland", []string{"display-server", "xorg"}, nil, nil), + ), []string{"i3", "wayland"}, workstation(), World{}) + + // wayland claiming to provide "xorg" is a manifest saying something untrue; what matters is + // that a real xorg module still wins when it exists, and that the answer is not silent. + if err != nil && !strings.Contains(err.Error(), "xorg") { + t.Errorf("unexpected refusal: %v", err) + } +} + +func mustDeclare(t *testing.T, r Resolution) []map[string]any { + t.Helper() + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + return out +} + +// A requirement answered on the same machine is still a requirement (novox/hq 04-ISSUES/021). +// +// **The machine stops being a trust boundary once both ends are containers.** A consumer reaches +// its provider over TCP from its own container, and the database asks for a password exactly as it +// would from another machine. Before this, two such modules resolved cleanly with zero needs: no +// credential was made, the consumer's secret file was never written, and whatever read it failed +// somewhere else entirely. +// +// It went unnoticed because everything proven until then was cross-machine, which is the +// interesting case for a mesh and the rare one in practice. +func TestSomethingAnsweredOnThisMachineIsStillANeed(t *testing.T) { + provider := Manifest{ + Module: "postgres", Version: "1", + Provides: FromAnywhere("postgres-database"), + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + } + consumer := Manifest{ + Module: "keycloak", Version: "1", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/database.env"}, + } + got, err := Resolve(shelf(provider, consumer), []string{"postgres", "keycloak"}, + Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 { + t.Fatalf("%d need(s); a consumer of a brokered provision needs a credential wherever "+ + "the provider is", len(got.Needs)) + } + if got.Needs[0].From != "anchor" { + t.Errorf("the need names %q as the provider, and it is this machine", got.Needs[0].From) + } + // And it carries what the provider says a consumer must know, which a local provider never + // contributes through the world. + if got.Needs[0].Serves["port"] != 5432 { + t.Errorf("the need carries %v, and the provider serves port 5432", got.Needs[0].Serves) + } +} + +// A name nothing grants is unchanged: answered here means answered, and nothing more is owed. +func TestSomethingOrdinaryAnsweredHereNeedsNothing(t *testing.T) { + got, err := Resolve(shelf( + mod("zsh", []string{"shell"}, nil, nil), + mod("editor-user", nil, []string{"shell"}, nil), + ), []string{"zsh", "editor-user"}, + Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 0 { + t.Fatalf("a shell answered on this machine produced %d need(s)", len(got.Needs)) + } +} + +func TestTheControllersOwnManifestClaimsItsSeat(t *testing.T) { + // The seat test above fabricates manifests, so deleting the claim from the real module.json + // would fail nothing (novox/hq issue 059's review). This binds the one manifest this + // repository owns: the controller claims the mesh-scoped seat named after its server + // (ADR 0079), or the one-controller property is convention again. + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatalf("the controller's own manifest is unreadable: %v", err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the controller's own manifest does not parse: %v", err) + } + for _, c := range m.Claims { + if c.Name == "mesh-controller" && c.At() == ScopeMesh { + return + } + } + t.Fatalf("module.json no longer claims the mesh-scoped mesh-controller seat: %+v", m.Claims) +} diff --git a/go/catalogue/resolved_path_test.go b/go/catalogue/resolved_path_test.go new file mode 100644 index 0000000..55b5519 --- /dev/null +++ b/go/catalogue/resolved_path_test.go @@ -0,0 +1,127 @@ +package catalogue + +// A definition's directories and files are judged at their resolved paths when the definition is (novox/hq issue +// 496). mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to /docker — +// /var/lib/docker, every container's filesystem. The merge gate composed every machine and passed it; only the +// node-engine refused it, at apply, and failed the walk. ParseManifest is what `module check`, registration and the +// merge gate's reading of a changed repository all run, so a refusal here is a refusal at each of them. + +import ( + "strings" + "testing" +) + +func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) { + // The #205 shape, as it was merged. + _, err := ParseManifest([]byte(`{"module": "docker", "version": "1", + "resources": [{"id": "state", "type": "directory", "place": "."}]}`)) + if err == nil { + t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply") + } + for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} { + if !strings.Contains(err.Error(), said) { + t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err) + } + } +} + +func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) { + // The fix #205 needed: the mesh's own directory for the module, /mesh/docker. + m, err := ParseManifest([]byte(`{"module": "docker", "version": "1", "resources": [ + {"id": "state", "type": "directory", "place": "mesh"}, + {"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}]}`)) + if err != nil { + t.Fatalf("place %q is in the mesh's tree, where the mesh writes for every module: %v", "mesh", err) + } + if got := dirsFor(m, Rendering{}); got["state"] != "/var/lib/mesh/docker" { + t.Fatalf("got %v", got) + } +} + +// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files. +func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { + refused := map[string]string{ + "a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [ + {"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`, + "a file in containerd's data": `{"module": "images", "version": "1", "resources": [ + {"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`, + "a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [ + {"id": "state", "type": "directory", "place": "mesh"}, + {"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`, + "a file in /boot": `{"module": "grub", "version": "1", "resources": [ + {"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`, + "a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [ + {"id": "all", "type": "directory", "place": "."}]}`, + "a directory that holds /etc": `{"module": "x", "version": "1", "resources": [ + {"id": "d", "type": "directory", "path": "/"}]}`, + "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1", + "resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`, + } + for name, raw := range refused { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") { + t.Errorf("%s: accepted, or refused for another reason: %v", name, err) + } + } + + // What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan): + // the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its + // own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here — + // below /lib and at /storage, /data, /services and /var/lock. + accepted := map[string]string{ + "a unit in /etc": `{"module": "power", "version": "1", "resources": [ + {"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"}, + {"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`, + "a run directory": `{"module": "fail2ban", "version": "1", "resources": [ + {"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`, + "a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [ + {"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`, + "the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [ + {"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"}, + {"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`, + "an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [ + {"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"}, + {"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`, + "a module's own root": `{"module": "mailu", "version": "1", "resources": [ + {"id": "state", "type": "directory", "place": "."}]}`, + "a file below /lib": `{"module": "udev", "version": "1", "resources": [ + {"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`, + "directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [ + {"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"}, + {"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`, + } + for name, raw := range accepted { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", name, err) + } + } +} + +func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) { + // A file's path may name an access; the access's default path is the definition's, so it is judged with it. + _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", + "accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}], + "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)) + if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") || + !strings.Contains(err.Error(), "issue 496") { + t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err) + } + // An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on + // the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it. + if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", + "accesses": [{"id": "images"}], + "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil { + t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err) + } +} + +func TestComposingAMachineIsNeverStoppedByOneModulesPath(t *testing.T) { + // A module registered from outside the catalogue never passes the gate. Refusing its path while a machine's + // declaration is composed would fail the whole declaration and freeze every module on that machine; the + // node-engine fails only the one resource. So composition leaves it to the engine. + m := Manifest{Module: "docker", Version: "1", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "."}, + }} + if _, err := (Resolution{Node: "anchor", Modules: []Manifest{m}}).Declaration(Rendering{}); err != nil { + t.Fatalf("the machine's declaration failed for one module's path: %v", err) + } +} diff --git a/go/catalogue/retirement_event_test.go b/go/catalogue/retirement_event_test.go new file mode 100644 index 0000000..ec02978 --- /dev/null +++ b/go/catalogue/retirement_event_test.go @@ -0,0 +1,22 @@ +package catalogue + +import ( + "slices" + "testing" +) + +// Every provider may say what becomes of a consumer the mesh stopped asking for (novox/hq ADR 0230), +// whatever its manifest lists — as it may say a consumer it keeps failing (ADR 0224) — and a module +// that receives nothing may not. +func TestEveryProviderMaySayWhatItRetires(t *testing.T) { + provider := Manifest{Module: "pg", Receives: map[string]string{"postgres-database": "/x/mesh.json"}, + Emits: []string{"database.provisioned"}} + for _, e := range []string{ProvisionerFailing, ProvisionerRecovered, ProvisionerRetirement, "database.provisioned"} { + if !slices.Contains(provider.EmitsAll(), e) { + t.Errorf("a provider may not emit %s: %v", e, provider.EmitsAll()) + } + } + if slices.Contains(Manifest{Module: "app", Emits: []string{"x"}}.EmitsAll(), ProvisionerRetirement) { + t.Error("a module that provides nothing may say what it retires") + } +} diff --git a/go/catalogue/roster_test.go b/go/catalogue/roster_test.go new file mode 100644 index 0000000..f278060 --- /dev/null +++ b/go/catalogue/roster_test.go @@ -0,0 +1,281 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Keyed by the internal name, as the control plane hands them (issue 079). bart has no address — +// the ordinary state between adding a machine and it joining. +var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} + +// A module says where it wants a roster file and in what format, and is given the rendered file. +func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, nil, "") + if err != nil { + t.Fatal(err) + } + if len(given) != 1 { + t.Fatalf("expected one file, got %d", len(given)) + } + if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { + t.Fatalf("not written where it was asked for: %v", given[0]) + } + // The id is fact-, which is what a restart-on names when the roster changes. + if given[0]["id"] != "fact-zones" { + t.Fatalf("the file's id is not fact-, so a restart-on cannot find it: %v", given[0]["id"]) + } + if !strings.Contains(given[0]["content"].(string), "address=/homer.internal/10.42.0.1") { + t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) + } +} + +// **A shared fact is written into a region of the machine's file, not over it** (novox/hq issue +// 128). A hosts file is the machine's — its localhost, the operator's lines, other tools' blocks — +// so the mesh owns only a marked region (`into: block`); a resolver's zones file is the mesh's +// whole, and carries no `into`. +func TestASharedFactIsWrittenIntoARegion(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + m := Manifest{Module: "net", Facts: map[string]RosterFile{ + "node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true}, + "node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, nil, "") + if err != nil { + t.Fatal(err) + } + by := map[string]map[string]any{} + for _, f := range given { + by[f["path"].(string)] = f + } + if by["/etc/hosts"]["into"] != "block" { + t.Fatalf("a shared fact is not written into a region, so the mesh writes the file whole: %v", by["/etc/hosts"]) + } + if _, has := by["/etc/zones"]["into"]; has { + t.Fatalf("an unshared fact was written into a region, so the mesh does not own its own file whole: %v", by["/etc/zones"]) + } +} + +// **The format is the module's — the mesh renders whatever template it gives.** The same roster +// through two templates is two entirely different files, and the control plane reads neither. +func TestTheFormatIsTheModulesOwn(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + hostsish := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}{{.Address}}\t{{.Name}}\n{{end}}"}}} + sshish := Manifest{Module: "b", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}} + + h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, nil, "") + if err != nil { + t.Fatal(err) + } + s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, nil, "") + if err != nil { + t.Fatal(err) + } + if h[0]["content"] != "10.42.0.1\thomer\n" { + t.Fatalf("the hosts-shaped template did not render its format: %q", h[0]["content"]) + } + if s[0]["content"] != "Host homer\n HostName homer.internal\n" { + t.Fatalf("the ssh-shaped template did not render its format: %q", s[0]["content"]) + } +} + +// **A template that will not parse is refused here, not on a machine.** A daemon that starts, reads +// a file the mesh could not render, and answers nothing is a much worse way to find out. +func TestABrokenTemplateIsRefusedHere(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}} + _, err := FactsInto(m, Resolution{}, nil, nil, nil, "") + if err == nil { + t.Fatal("a template that does not parse was accepted, so the machine gets an empty file") + } + if !strings.Contains(err.Error(), "resolver") || !strings.Contains(err.Error(), "zones") { + t.Fatalf("the refusal does not say whose template, or which: %v", err) + } +} + +// A template reading something the mesh does not compute is refused, not rendered empty. The roster +// is a closed shape; asking it for the weather fails where the manifest is. +func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, nil, ""); err == nil { + t.Fatal("a template read a field nobody computes and rendered anyway, silently") + } +} + +// A relative path is refused, or a module decides where the mesh writes on a machine. +func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "hosts": {Path: "etc/hosts", Template: "x"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, nil, ""); err == nil { + t.Fatal("a relative path was accepted") + } +} + +// A machine the mesh has a record for and cannot place is left out of the roster. +// +// **Not an oversight — the alternative is worse.** A name written with no address resolves to +// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is +// unknown, which is a thing somebody can act on. +func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) { + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, nil, "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(given[0]["content"].(string), "bart") { + t.Fatalf("a machine with no address was in the roster, so its name resolves to nothing:\n%s", given[0]["content"]) + } +} + +// **The names the control plane hands a resolution are already internal names** — `homer.internal`, +// the same map every container gets as its hosts. A roster entry's FQDN is that name, not it with +// the suffix appended a second time; either key gives the same entries. +func TestNamesAreNotSuffixedTwice(t *testing.T) { + internal := map[string]string{"homer.internal": "10.42.0.1"} + bare := map[string]string{"homer": "10.42.0.1"} + tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"} + + fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, nil, "") + if err != nil { + t.Fatal(err) + } + fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, nil, "") + if err != nil { + t.Fatal(err) + } + if fromInternal[0]["content"] != fromBare[0]["content"] { + t.Fatalf("the roster differs by how the names were keyed:\n%q\n%q", fromInternal[0]["content"], fromBare[0]["content"]) + } + got := fromInternal[0]["content"].(string) + if strings.Contains(got, "internal.internal") || !strings.Contains(got, "homer.internal homer") { + t.Fatalf("the entry carries a doubled suffix or the wrong bare name:\n%s", got) + } +} + +// The suffix the control plane composed the names with is the one a template sees — an operator who +// chose another does not get `.internal`. `.Suffix` is the bare form, and FQDNs carry it. +func TestTheSuffixIsCarriedAsComposed(t *testing.T) { + names := map[string]string{"homer.lan": "10.42.0.1"} + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, nil, "lan") + if err != nil { + t.Fatal(err) + } + got := given[0]["content"].(string) + if !strings.Contains(got, "local=/lan/") || strings.Contains(got, "internal") { + t.Fatalf("the operator's suffix was not carried, so its names would be wrong:\n%s", got) + } + if !strings.Contains(got, "homer.lan") { + t.Fatalf("the FQDN does not carry the operator's suffix:\n%s", got) + } +} + +// novox/hq 04-ISSUES/111: a template is given both sets and chooses. `.Names` is every name the mesh +// serves — the machines and the names it was told to route; `.Machines` is only the machines. A +// container's hosts wants every name so a routed name resolves to the machine serving it; a resolver +// told the suffix is its own wants only the machines, or a routed name written there with the suffix +// is a name nobody will ever ask for, standing beside the machines and looking as real. +func TestATemplateChoosesMachinesOrEveryName(t *testing.T) { + machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + every := map[string]string{ + "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", + "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", + } + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + "hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, nil, "") + if err != nil { + t.Fatal(err) + } + by := map[string]string{} + for _, f := range given { + by[f["path"].(string)] = f["content"].(string) + } + + zones := by["/etc/zones"] + for _, served := range []string{"drive.example.test", "git.example.test"} { + if strings.Contains(zones, served) { + t.Fatalf("a template over .Machines saw %q, a name the mesh serves rather than a machine:\n%s", served, zones) + } + } + if !strings.Contains(zones, "homer.internal") { + t.Fatalf("a template over .Machines did not see the machines:\n%s", zones) + } + + hosts := by["/etc/hosts"] + for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { + if !strings.Contains(hosts, name) { + t.Fatalf("a template over .Names did not see %q, so a container would not resolve it:\n%s", name, hosts) + } + } +} + +// A home fact is placed under the operator account's home and chowned to it, and its template sees +// each node's account (novox/hq to-be 29) — the ssh-client config is the case. +func TestAHomeFactIsPlacedUnderTheAccountsHomeAndOwnedByIt(t *testing.T) { + names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + accounts := map[string]string{"homer": "jo", "marge": "jo"} + m := Manifest{Module: "ssh-client", Facts: map[string]RosterFile{ + "ssh-config": {Path: ".ssh/config.d/mesh", Home: true, + Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n User {{.Account}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer", Account: "jo"}, names, names, accounts, "") + if err != nil { + t.Fatal(err) + } + f := given[0] + if f["path"] != "/home/jo/.ssh/config.d/mesh" { + t.Fatalf("the home fact was not placed under the account's home: %v", f["path"]) + } + if f["owner"] != "jo" { + t.Fatalf("the home fact is not owned by the account: %v", f["owner"]) + } + if !strings.Contains(f["content"].(string), "Host marge\n HostName marge.internal\n User jo") { + t.Fatalf("the config does not name the peer's account:\n%s", f["content"]) + } +} + +// A machine with no operator account gets no home fact — it cannot be placed, so it is left out +// rather than written to nowhere. +func TestAHomeFactIsSkippedWhereThereIsNoAccount(t *testing.T) { + names := map[string]string{"homer.internal": "10.42.0.1"} + m := Manifest{Module: "ssh-client", Facts: map[string]RosterFile{ + "ssh-config": {Path: ".ssh/config", Home: true, Template: "x"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, nil, "") + if err != nil { + t.Fatal(err) + } + if len(given) != 0 { + t.Fatalf("a home fact was placed on a machine with no operator account: %v", given) + } +} + +// A routed name is already a full name under a public domain and has no mesh form. Appending the +// suffix to it made `git.example.tld.internal` — carried by every machine's hosts file, served by +// nothing, and refused by the proxy at the handshake (novox/hq issue 157). It is published as +// itself, and only a machine has a bare name beside its full one. +func TestARoutedNameIsPublishedAsItselfAndNotSuffixed(t *testing.T) { + names := map[string]string{"homer.internal": "10.42.0.1", "git.example.tld": "10.42.0.1"} + tmpl := RosterFile{Path: "/f", Template: "{{range .Names}}{{.FQDN}} {{.Name}}\n{{end}}"} + out, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, + Resolution{Node: "homer"}, names, map[string]string{"homer.internal": "10.42.0.1"}, nil, "internal") + if err != nil { + t.Fatal(err) + } + got := out[0]["content"].(string) + if strings.Contains(got, "tld.internal") { + t.Fatalf("the routed name was given a suffixed alias that nothing serves:\n%s", got) + } + if !strings.Contains(got, "git.example.tld git.example.tld\n") || !strings.Contains(got, "homer.internal homer\n") { + t.Fatalf("the roster does not carry the routed name as itself beside the machine's two forms:\n%s", got) + } +} diff --git a/go/catalogue/routing_test.go b/go/catalogue/routing_test.go new file mode 100644 index 0000000..bb6a115 --- /dev/null +++ b/go/catalogue/routing_test.go @@ -0,0 +1,256 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0066 — public routing is name-agnostic. +// +// A route contribution carries a label (the subdomain the operator chose); the node carries its +// public domain; the mesh composes