From e4364a1c86bee954a89dce99fd56928f3a725a15 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 08:30:14 +0000 Subject: [PATCH] Delete the first half of internal/catalogue's tests: mesh-sdk's go/catalogue holds them (issue 1381) The tests that read this repository's own files (controller_is_a_process, genesis_image, genesis_registry and foundation_manifests' registry test) live on in internal/ownmanifest; engine_guard_test and the rest are in mesh-sdk (issue 1379). --- internal/catalogue/a_service_process_test.go | 166 ---- internal/catalogue/accesses_test.go | 108 -- internal/catalogue/adoption_test.go | 683 ------------- internal/catalogue/agent_account_test.go | 119 --- .../catalogue/amqp_is_not_a_provision_test.go | 50 - .../catalogue/artifact_store_alias_test.go | 18 - .../catalogue/artifact_store_seat_test.go | 38 - internal/catalogue/artifacts_test.go | 137 --- internal/catalogue/backup_placement_test.go | 126 --- internal/catalogue/backup_test.go | 47 - internal/catalogue/bootstrap_cycle_test.go | 71 -- internal/catalogue/bound_into_files_test.go | 265 ----- internal/catalogue/bound_test.go | 257 ----- internal/catalogue/broker_seat_test.go | 87 -- internal/catalogue/brokered_test.go | 332 ------- internal/catalogue/build_system_test.go | 82 -- internal/catalogue/build_test.go | 203 ---- internal/catalogue/bus_snapshot_test.go | 80 -- internal/catalogue/busy_test.go | 94 -- internal/catalogue/catalogue_check_test.go | 138 --- internal/catalogue/catrust_manifest_test.go | 69 -- internal/catalogue/co_located_test.go | 315 ------ internal/catalogue/collections_bar_test.go | 135 --- internal/catalogue/collections_l2_test.go | 60 -- internal/catalogue/collections_l3_test.go | 158 --- internal/catalogue/collections_test.go | 179 ---- internal/catalogue/computed_test.go | 205 ---- internal/catalogue/config_rename_test.go | 185 ---- internal/catalogue/config_test.go | 484 --------- internal/catalogue/consumes_start_test.go | 24 - internal/catalogue/contributes_test.go | 412 -------- .../contribution_dependencies_test.go | 136 --- .../catalogue/controller_is_a_process_test.go | 131 --- internal/catalogue/core_credentials_test.go | 107 -- internal/catalogue/data_test.go | 227 ----- internal/catalogue/declaration_test.go | 80 -- internal/catalogue/delivery_seat_test.go | 164 ---- .../catalogue/derived_for_consumer_test.go | 346 ------- internal/catalogue/dir_into_test.go | 325 ------- internal/catalogue/domain_test.go | 134 --- internal/catalogue/endpoint_test.go | 205 ---- internal/catalogue/endpoints_setting_test.go | 140 --- internal/catalogue/engine_guard_test.go | 71 -- .../environment_into_integration_test.go | 91 -- internal/catalogue/environment_into_test.go | 424 -------- internal/catalogue/environment_items_test.go | 165 ---- internal/catalogue/files_test.go | 111 --- .../catalogue/filtering_foundation_test.go | 58 -- internal/catalogue/filtering_test.go | 920 ------------------ .../catalogue/foundation_manifests_test.go | 368 ------- .../catalogue/generated_collision_test.go | 85 -- internal/catalogue/genesis_image_test.go | 29 - internal/catalogue/genesis_registry_test.go | 80 -- internal/catalogue/go_bundle_test.go | 37 - internal/catalogue/grant_secret_owner_test.go | 103 -- internal/catalogue/graphical_session_test.go | 213 ---- internal/catalogue/handed_bound_test.go | 159 --- internal/catalogue/handed_consumers_test.go | 134 --- internal/catalogue/handed_merge_test.go | 57 -- internal/catalogue/handed_providers_test.go | 274 ------ internal/catalogue/handed_pulled_in_test.go | 121 --- internal/catalogue/handed_secrets_test.go | 252 ----- internal/catalogue/handed_test.go | 188 ---- internal/catalogue/health_test.go | 217 ----- internal/catalogue/holdings_test.go | 194 ---- internal/catalogue/holds_test.go | 100 -- internal/catalogue/hostname_seat_test.go | 98 -- internal/catalogue/identity_bound_test.go | 190 ---- internal/catalogue/identity_test.go | 53 - internal/catalogue/installation_test.go | 93 -- internal/catalogue/invokes_test.go | 31 - internal/catalogue/issued_credential_test.go | 42 - internal/catalogue/issued_outside_test.go | 46 - internal/catalogue/jail_problems_test.go | 49 - internal/catalogue/jails_into_test.go | 72 -- internal/catalogue/kept_test.go | 68 -- internal/catalogue/kinded_test.go | 168 ---- internal/catalogue/licence_test.go | 269 ----- internal/catalogue/localnetwork_test.go | 195 ---- .../catalogue/login_shell_execute_test.go | 41 - internal/catalogue/logs_test.go | 68 -- internal/catalogue/machine_into_files_test.go | 128 --- internal/catalogue/machineside_test.go | 60 -- internal/catalogue/mounts_test.go | 104 -- internal/catalogue/names_test.go | 100 -- internal/catalogue/naming_test.go | 75 -- internal/catalogue/needs_test.go | 124 --- internal/catalogue/no_subjects_test.go | 124 --- internal/catalogue/node_resolver_test.go | 216 ---- internal/catalogue/operators_machine_test.go | 191 ---- .../catalogue/optional_requirements_test.go | 301 ------ 91 files changed, 14679 deletions(-) delete mode 100644 internal/catalogue/a_service_process_test.go delete mode 100644 internal/catalogue/accesses_test.go delete mode 100644 internal/catalogue/adoption_test.go delete mode 100644 internal/catalogue/agent_account_test.go delete mode 100644 internal/catalogue/amqp_is_not_a_provision_test.go delete mode 100644 internal/catalogue/artifact_store_alias_test.go delete mode 100644 internal/catalogue/artifact_store_seat_test.go delete mode 100644 internal/catalogue/artifacts_test.go delete mode 100644 internal/catalogue/backup_placement_test.go delete mode 100644 internal/catalogue/backup_test.go delete mode 100644 internal/catalogue/bootstrap_cycle_test.go delete mode 100644 internal/catalogue/bound_into_files_test.go delete mode 100644 internal/catalogue/bound_test.go delete mode 100644 internal/catalogue/broker_seat_test.go delete mode 100644 internal/catalogue/brokered_test.go delete mode 100644 internal/catalogue/build_system_test.go delete mode 100644 internal/catalogue/build_test.go delete mode 100644 internal/catalogue/bus_snapshot_test.go delete mode 100644 internal/catalogue/busy_test.go delete mode 100644 internal/catalogue/catalogue_check_test.go delete mode 100644 internal/catalogue/catrust_manifest_test.go delete mode 100644 internal/catalogue/co_located_test.go delete mode 100644 internal/catalogue/collections_bar_test.go delete mode 100644 internal/catalogue/collections_l2_test.go delete mode 100644 internal/catalogue/collections_l3_test.go delete mode 100644 internal/catalogue/collections_test.go delete mode 100644 internal/catalogue/computed_test.go delete mode 100644 internal/catalogue/config_rename_test.go delete mode 100644 internal/catalogue/config_test.go delete mode 100644 internal/catalogue/consumes_start_test.go delete mode 100644 internal/catalogue/contributes_test.go delete mode 100644 internal/catalogue/contribution_dependencies_test.go delete mode 100644 internal/catalogue/controller_is_a_process_test.go delete mode 100644 internal/catalogue/core_credentials_test.go delete mode 100644 internal/catalogue/data_test.go delete mode 100644 internal/catalogue/declaration_test.go delete mode 100644 internal/catalogue/delivery_seat_test.go delete mode 100644 internal/catalogue/derived_for_consumer_test.go delete mode 100644 internal/catalogue/dir_into_test.go delete mode 100644 internal/catalogue/domain_test.go delete mode 100644 internal/catalogue/endpoint_test.go delete mode 100644 internal/catalogue/endpoints_setting_test.go delete mode 100644 internal/catalogue/engine_guard_test.go delete mode 100644 internal/catalogue/environment_into_integration_test.go delete mode 100644 internal/catalogue/environment_into_test.go delete mode 100644 internal/catalogue/environment_items_test.go delete mode 100644 internal/catalogue/files_test.go delete mode 100644 internal/catalogue/filtering_foundation_test.go delete mode 100644 internal/catalogue/filtering_test.go delete mode 100644 internal/catalogue/foundation_manifests_test.go delete mode 100644 internal/catalogue/generated_collision_test.go delete mode 100644 internal/catalogue/genesis_image_test.go delete mode 100644 internal/catalogue/genesis_registry_test.go delete mode 100644 internal/catalogue/go_bundle_test.go delete mode 100644 internal/catalogue/grant_secret_owner_test.go delete mode 100644 internal/catalogue/graphical_session_test.go delete mode 100644 internal/catalogue/handed_bound_test.go delete mode 100644 internal/catalogue/handed_consumers_test.go delete mode 100644 internal/catalogue/handed_merge_test.go delete mode 100644 internal/catalogue/handed_providers_test.go delete mode 100644 internal/catalogue/handed_pulled_in_test.go delete mode 100644 internal/catalogue/handed_secrets_test.go delete mode 100644 internal/catalogue/handed_test.go delete mode 100644 internal/catalogue/health_test.go delete mode 100644 internal/catalogue/holdings_test.go delete mode 100644 internal/catalogue/holds_test.go delete mode 100644 internal/catalogue/hostname_seat_test.go delete mode 100644 internal/catalogue/identity_bound_test.go delete mode 100644 internal/catalogue/identity_test.go delete mode 100644 internal/catalogue/installation_test.go delete mode 100644 internal/catalogue/invokes_test.go delete mode 100644 internal/catalogue/issued_credential_test.go delete mode 100644 internal/catalogue/issued_outside_test.go delete mode 100644 internal/catalogue/jail_problems_test.go delete mode 100644 internal/catalogue/jails_into_test.go delete mode 100644 internal/catalogue/kept_test.go delete mode 100644 internal/catalogue/kinded_test.go delete mode 100644 internal/catalogue/licence_test.go delete mode 100644 internal/catalogue/localnetwork_test.go delete mode 100644 internal/catalogue/login_shell_execute_test.go delete mode 100644 internal/catalogue/logs_test.go delete mode 100644 internal/catalogue/machine_into_files_test.go delete mode 100644 internal/catalogue/machineside_test.go delete mode 100644 internal/catalogue/mounts_test.go delete mode 100644 internal/catalogue/names_test.go delete mode 100644 internal/catalogue/naming_test.go delete mode 100644 internal/catalogue/needs_test.go delete mode 100644 internal/catalogue/no_subjects_test.go delete mode 100644 internal/catalogue/node_resolver_test.go delete mode 100644 internal/catalogue/operators_machine_test.go delete mode 100644 internal/catalogue/optional_requirements_test.go diff --git a/internal/catalogue/a_service_process_test.go b/internal/catalogue/a_service_process_test.go deleted file mode 100644 index 1e252767..00000000 --- a/internal/catalogue/a_service_process_test.go +++ /dev/null @@ -1,166 +0,0 @@ -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/internal/catalogue/accesses_test.go b/internal/catalogue/accesses_test.go deleted file mode 100644 index b95250af..00000000 --- a/internal/catalogue/accesses_test.go +++ /dev/null @@ -1,108 +0,0 @@ -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/internal/catalogue/adoption_test.go b/internal/catalogue/adoption_test.go deleted file mode 100644 index 8630fa4e..00000000 --- a/internal/catalogue/adoption_test.go +++ /dev/null @@ -1,683 +0,0 @@ -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/internal/catalogue/agent_account_test.go b/internal/catalogue/agent_account_test.go deleted file mode 100644 index 90efb041..00000000 --- a/internal/catalogue/agent_account_test.go +++ /dev/null @@ -1,119 +0,0 @@ -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/internal/catalogue/amqp_is_not_a_provision_test.go b/internal/catalogue/amqp_is_not_a_provision_test.go deleted file mode 100644 index 932c81df..00000000 --- a/internal/catalogue/amqp_is_not_a_provision_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package catalogue - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants -// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming -// the old wire protocol asks for the one server being retired, so both directions are refused at the -// parser — this is judged from the manifest alone, no store needed. - -func TestAManifestProvidingAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) { - t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err) - } -} - -func TestAManifestRequiringAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) { - t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err) - } -} - -// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under -// design 28 task 5.4, not converted. -func TestNoCatalogueManifestNamesAmqp(t *testing.T) { - modules, err := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) - if err != nil || len(modules) == 0 { - t.Fatalf("no manifests in the catalogue, so this proved nothing: %v", err) - } - for _, path := range modules { - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), `"amqp"`) { - t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)", - filepath.Base(filepath.Dir(path))) - } - } -} diff --git a/internal/catalogue/artifact_store_alias_test.go b/internal/catalogue/artifact_store_alias_test.go deleted file mode 100644 index 761d4768..00000000 --- a/internal/catalogue/artifact_store_alias_test.go +++ /dev/null @@ -1,18 +0,0 @@ -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/internal/catalogue/artifact_store_seat_test.go b/internal/catalogue/artifact_store_seat_test.go deleted file mode 100644 index 182dcac8..00000000 --- a/internal/catalogue/artifact_store_seat_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// The artifact store's seat is one per mesh, read from the catalogue beside this checkout. -// -// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat -// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a -// node-scoped requirement with one candidate installs that candidate on the node, so anything that -// required the store's presence beside it would have raised a fresh, empty store on the wrong -// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every -// consumer elsewhere refusing to choose. The claim says it first, where the second store is -// assigned. -func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { - store := catalogueManifest(t, "distribution") - - // The first store resolves as it always has. - if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), World{}); err != nil { - t.Fatalf("the store alone does not resolve: %v", err) - } - - // A second one, on any other machine, is refused — and the refusal names the seat. - elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh, - Node: "anchor", Module: "distribution"}}} - other := workstation() - other.Name = "laptop" - _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere) - if err == nil { - t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + - "second time and every consumer elsewhere would refuse to choose") - } - if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { - t.Fatalf("refused without naming the seat: %v", err) - } -} diff --git a/internal/catalogue/artifacts_test.go b/internal/catalogue/artifacts_test.go deleted file mode 100644 index 3d0ae70e..00000000 --- a/internal/catalogue/artifacts_test.go +++ /dev/null @@ -1,137 +0,0 @@ -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/internal/catalogue/backup_placement_test.go b/internal/catalogue/backup_placement_test.go deleted file mode 100644 index ba566ca2..00000000 --- a/internal/catalogue/backup_placement_test.go +++ /dev/null @@ -1,126 +0,0 @@ -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/internal/catalogue/backup_test.go b/internal/catalogue/backup_test.go deleted file mode 100644 index 4450ac27..00000000 --- a/internal/catalogue/backup_test.go +++ /dev/null @@ -1,47 +0,0 @@ -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/internal/catalogue/bootstrap_cycle_test.go b/internal/catalogue/bootstrap_cycle_test.go deleted file mode 100644 index 9cbbd670..00000000 --- a/internal/catalogue/bootstrap_cycle_test.go +++ /dev/null @@ -1,71 +0,0 @@ -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/internal/catalogue/bound_into_files_test.go b/internal/catalogue/bound_into_files_test.go deleted file mode 100644 index 7abfa3e7..00000000 --- a/internal/catalogue/bound_into_files_test.go +++ /dev/null @@ -1,265 +0,0 @@ -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/internal/catalogue/bound_test.go b/internal/catalogue/bound_test.go deleted file mode 100644 index d152fff4..00000000 --- a/internal/catalogue/bound_test.go +++ /dev/null @@ -1,257 +0,0 @@ -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/internal/catalogue/broker_seat_test.go b/internal/catalogue/broker_seat_test.go deleted file mode 100644 index c04389ab..00000000 --- a/internal/catalogue/broker_seat_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// The mesh's bus is one per mesh, read from the catalogue beside this checkout. -// -// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116): -// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running, -// and the property that matters is that a second one anywhere is refused *when it is assigned*, -// not discovered later as two servers holding different halves of the mesh's traffic. A second -// bus is not a degraded mesh; it is two meshes that both believe they are the one. -func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) { - nats := catalogueManifest(t, "nats") - - if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), World{}); err != nil { - t.Fatalf("the bus alone does not resolve: %v", err) - } - - elsewhere := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, - Node: "anchor", Module: "nats"}}} - other := workstation() - other.Name = "laptop" - _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere) - if err == nil { - t.Fatal("a second bus was accepted on another machine") - } - if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") { - t.Fatalf("refused without naming the seat: %v", err) - } -} - -// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different -// implementation of the bus claims the same seat, and the mesh refuses it for the same reason — -// which is the property that lets the bus be replaced at all. -func TestTheSeatRefusesADifferentBusToo(t *testing.T) { - nats := catalogueManifest(t, "nats") - held := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, - Node: "anchor", Module: "some-other-broker"}}} - other := workstation() - other.Name = "laptop" - if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil { - t.Fatal("the seat admitted a second holder because the module's name differed") - } -} - -// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no -// longer a fixture here. That two eligible holders stand beside each other with one on record is -// pinned in holdings_test.go against manifests this package owns. - -// **A seat and the interface it delivers are different names, and renaming one must not rename -// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*` -// prefix, and a blanket search-and-replace also renamed `npm-package-registry` and `git` where -// they are *provisions* — which a consumer requires and a provider offers. The tests failed with -// "the package registry is served on ", which does not say "you renamed an interface". -func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) { - for _, pair := range []struct{ seat, delivers string }{ - {"git", "git"}, - {"npm-package-registry", "npm-package-registry"}, - {"mesh-artifact-store", "artifact-store"}, - {"mesh-store", "postgres-database"}, - {"mesh-broker", "mesh-bus"}, - } { - s, known := SeatNamed(pair.seat) - if !known { - t.Fatalf("%q is not a seat", pair.seat) - } - if s.Delivers != pair.delivers { - t.Errorf("the %s seat delivers %q, expected %q — renaming the seat moved the "+ - "interface with it, and every consumer requiring it would stop resolving", - pair.seat, s.Delivers, pair.delivers) - } - // **Three of these deliberately share a name with what they deliver**, and that is not an - // incomplete rename. Renaming a seat that delivers a provision cascades to every consumer - // requiring it, with a mesh-wide window where a holder stops resolving mid-flight — so the - // trunk deferred exactly those three (novox/hq ADR 0121) while renaming the node-scoped ones. - // What this test is for is the other direction: that renaming a seat never moves the - // interface, which once produced "the package registry is served on ". - } -} - -// A manifest written against an old seat name is told what it became rather than refused as -// unknown. **That map is the controller's store now, not this package** (novox/hq ADR 0122): a -// rename is a row, so the courtesy survives a rename nobody recompiled for. Checked where the -// table is read, not here, where there is no longer a hardcoded list to check against. diff --git a/internal/catalogue/brokered_test.go b/internal/catalogue/brokered_test.go deleted file mode 100644 index c379f718..00000000 --- a/internal/catalogue/brokered_test.go +++ /dev/null @@ -1,332 +0,0 @@ -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/internal/catalogue/build_system_test.go b/internal/catalogue/build_system_test.go deleted file mode 100644 index e1a97dd1..00000000 --- a/internal/catalogue/build_system_test.go +++ /dev/null @@ -1,82 +0,0 @@ -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/internal/catalogue/build_test.go b/internal/catalogue/build_test.go deleted file mode 100644 index c17c0ebe..00000000 --- a/internal/catalogue/build_test.go +++ /dev/null @@ -1,203 +0,0 @@ -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/internal/catalogue/bus_snapshot_test.go b/internal/catalogue/bus_snapshot_test.go deleted file mode 100644 index e9904350..00000000 --- a/internal/catalogue/bus_snapshot_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package catalogue - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and -// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted -// nothing, so it is refused at the parser rather than silently dropped. -func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { - raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "emits":["something.happened"]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") { - t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) - } - - // Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is - // granted the module's events, so a bus module that ships one may declare them. - bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "emits":["bus.slow-consumer"],"tools":["bus_streams"], - "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`) - if _, err := ParseManifest(bundled); err != nil { - t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err) - } - - quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "tools":["bus_streams"]}`) - m, err := ParseManifest(quiet) - if err != nil { - t.Fatalf("a bus module with an account and tools was refused: %v", err) - } - if !m.ClaimsSeat(BrokerSeat) { - t.Fatal("it does not read as holding the broker seat") - } -} - -// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a -// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot -// program in the bus's own container. -func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) { - raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "nats", "module.json")) - if err != nil { - t.Fatal(err) - } - m, err := ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - if _, account := m.OwnSecrets["broker"]; !account { - t.Fatal("the bus declares no account, so its snapshot could not reach it") - } - var found bool - if m.Data == nil { - t.Fatal("the bus declares no data") - } - for _, it := range m.Data.Own { - if it.ID != "jetstream" { - continue - } - found = true - if !it.Backup.IsDump() || it.Backup.Into != "snapshots" { - t.Fatalf("the bus's streams are protected by %+v, not a snapshot into its snapshots", it.Backup) - } - if !strings.Contains(it.Backup.Dump, "mesh-nats-snapshot snapshot") { - t.Fatalf("the dump does not run the snapshot program: %s", it.Backup.Dump) - } - } - if !found { - t.Fatal("the bus declares no item for its streams") - } -} diff --git a/internal/catalogue/busy_test.go b/internal/catalogue/busy_test.go deleted file mode 100644 index 871817d2..00000000 --- a/internal/catalogue/busy_test.go +++ /dev/null @@ -1,94 +0,0 @@ -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/internal/catalogue/catalogue_check_test.go b/internal/catalogue/catalogue_check_test.go deleted file mode 100644 index f853c010..00000000 --- a/internal/catalogue/catalogue_check_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package catalogue - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. -// -// Not a fixture: the point is whether the manifests as written are accepted by the control plane that -// will read them, and a copy of one manifest proves nothing about the other seventy-one. -// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put -// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only -// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read -// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. -func catalogueRoot(t *testing.T) string { - t.Helper() - return beside.Dir(t, "mesh-catalog") -} - -func TestEveryCatalogueManifestParses(t *testing.T) { - root := catalogueRoot(t) - found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) - if err != nil || len(found) == 0 { - t.Fatalf("no manifests under %s: %v", root, err) - } - named, routed := 0, 0 - for _, p := range found { - m, err := ReadTreeManifest(root, filepath.Dir(p)) - if err != nil { - t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err) - continue - } - for _, l := range m.Listens { - if l.Name != "" { - named++ - } - } - for port := range RoutedPorts(m) { - _ = port - routed++ - } - } - t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed) - if named == 0 { - t.Fatal("no endpoint in the catalogue is named, so this proved nothing") - } -} - -// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no -// definition names a domain or a public address the mesh acts on, and every value that must for now -// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks. -func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) { - root := catalogueRoot(t) - found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) - if err != nil || len(found) == 0 { - t.Fatalf("no manifests under %s: %v", root, err) - } - var named []string - for _, p := range found { - m, err := ReadTreeManifest(root, filepath.Dir(p)) - if err != nil { - t.Errorf("%s: %v", p, err) - continue - } - named = append(named, InstallationProblems(m)...) - } - if len(named) > 0 { - t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n ")) - } -} - -// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR -// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a -// backup line by hand, every directory a container writes is declared, and every irreplaceable item is -// backed up — so a store added is a store backed up, without a list of stores to keep in step. -func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) { - root := catalogueRoot(t) - found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) - if err != nil || len(found) == 0 { - t.Fatalf("no manifests under %s: %v", root, err) - } - shelf := Shelf{} - granting := 0 - for _, p := range found { - m, err := ReadTreeManifest(root, filepath.Dir(p)) - if err != nil { - continue // TestEveryCatalogueManifestParses says why - } - if len(m.Grants) > 0 { - granting++ - } - shelf[m.Module] = m - } - for _, problem := range DataProblems(shelf) { - t.Error(problem) - } - if granting == 0 { - t.Fatal("no module in the catalogue grants a provision, so this proved nothing") - } -} - -// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every -// module's identity, on the longest machine name, fits the bound of every provision it wants. An -// overflow fails here, in the pull request that introduces it, rather than on the provider's machine -// the first time a real machine's name meets the module's (issue 263). -func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) { - root := catalogueRoot(t) - found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) - if err != nil || len(found) == 0 { - t.Fatalf("no manifests under %s: %v", root, err) - } - shelf := Shelf{} - for _, p := range found { - m, err := ReadTreeManifest(root, filepath.Dir(p)) - if err != nil { - t.Fatalf("%s: %v", p, err) - } - shelf[m.Module] = m - } - for _, p := range IdentityProblems(shelf, DefaultLongestMachine) { - t.Error(p) - } - // The night it was found: the resolver provision bounds nothing, and the object store still 20. - if dns, ok := shelf["dnsmasq"]; ok { - if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() { - t.Errorf("the resolver provision bounds its consumers' identities: %+v", b) - } - } - if store, ok := shelf["minio"]; ok { - if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 { - t.Errorf("the object store's access key is not bounded at 20: %+v", b) - } - } -} diff --git a/internal/catalogue/catrust_manifest_test.go b/internal/catalogue/catrust_manifest_test.go deleted file mode 100644 index dfad82cc..00000000 --- a/internal/catalogue/catrust_manifest_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package catalogue - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR -// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless -// the mesh fills in where the authority is — which is the one thing about it the manifest cannot -// state, because the authority's address is a fact about the mesh and not about the module. -// -// So what is checked here is the rendering, not the parsing: the script the machine will run -// names the authority it was bound to, and the unit runs that script both ways. The verification -// itself — a plain client trusting an internal name on a machine holding this, and failing on one -// that does not — is the lab's, and cannot be had here. -func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { - m, err := ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) - if err != nil { - t.Fatalf("the trust module does not parse:\n%v", err) - } - - r := Resolution{ - Node: "workstation", - Modules: []Manifest{m}, - Needs: []Needed{{ - Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", - Serves: map[string]any{ - "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", - }, - }}, - } - out, err := r.Declaration(Rendering{}) - if err != nil { - t.Fatalf("the trust module could not be composed for a machine: %v", err) - } - - script := fileNamed(out, "ca-trust.anchor") - if script == nil { - t.Fatalf("nothing writes the script the unit runs: %v", out) - } - body, _ := script["content"].(string) - if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { - t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) - } - if script["mode"] != "0755" { - t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) - } - - unit := fileNamed(out, "ca-trust.unit") - if unit == nil { - t.Fatalf("no unit: %v", out) - } - text, _ := unit["content"].(string) - // Both halves. A unit that only installs the anchor leaves a machine trusting an authority - // nobody assigned it to any more, which is the half issue 129 asked for by name. - for _, want := range []string{ - "ExecStart=" + script["path"].(string) + " install", - "ExecStop=" + script["path"].(string) + " remove", - "RemainAfterExit=yes", - } { - if !strings.Contains(text, want) { - t.Errorf("the unit does not say %q:\n%s", want, text) - } - } -} diff --git a/internal/catalogue/co_located_test.go b/internal/catalogue/co_located_test.go deleted file mode 100644 index 340a8138..00000000 --- a/internal/catalogue/co_located_test.go +++ /dev/null @@ -1,315 +0,0 @@ -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/internal/catalogue/collections_bar_test.go b/internal/catalogue/collections_bar_test.go deleted file mode 100644 index 54de9d6e..00000000 --- a/internal/catalogue/collections_bar_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package catalogue - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -// A bar's blocks are a collection of text (design 57 L3, issue 781): the holder of node-bar names the kind as -// `${collected:contribution:node-bar:block:bar=,place=

}`, which a handed collector has its client fill from the -// items and an unhanded one has the controller fill as it fills the old placeholder. - -const ( - collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" + - "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}" - collectedBars = `{"module":"bars","version":"1","moved":true, - "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` - itemsOfPower = `{"module":"power","version":"1","moved":true, - "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, - {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` -) - -func collectedBarsOn(t *testing.T, caps map[string]bool) Resolution { - t.Helper() - bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) - power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"})) - r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account, r.Capabilities = "op", caps - return r -} - -func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) { - bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) - if _, err := collectedBarsOn(t, nil).Compose(handingTo("store:5000")); err != nil { - t.Fatalf("the handed collector is refused: %v", err) - } - if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x", - "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 { - t.Fatalf("the check refuses it: %v", got) - } - // A data kind whose shape does not say its items are text stays refused. - _, plain, _ := receivable(BarSeat, BarKindBlock) - plain.Shape = &Shape{} - if collectableAsText(plain) { - t.Fatal("a shape not marked as text is collectable") - } - if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil { - t.Fatal(err) - } -} - -func TestTheBarsCollectionIsFilledTheOldWayWhereTheCollectorIsNotHanded(t *testing.T) { - for _, caps := range []map[string]bool{nil, {"battery": true}} { - got := composedFile(t, collectedBarsOn(t, caps), "bars.conf") - want := "top\nbottom\n# power\nD workstation\n" - if caps["battery"] { - want = "top\nbottom\n# power\nB\n# power\nD workstation\n" - } - if got != want { - t.Errorf("caps %v: the file is %q, want %q", caps, got, want) - } - } -} - -func TestTheBarsCollectionIsNamedInTheHandedPlan(t *testing.T) { - c, _ := composeWith(t, collectedBarsOn(t, map[string]bool{"battery": true}), handingTo("store:5000")) - if !reflect.DeepEqual(c.Plans["bars"].Collections, []PlanCollection{{Name: "contribution:node-bar:block", Comment: "#"}}) { - t.Fatalf("the plan collects %+v", c.Plans["bars"].Collections) - } - if n := len(c.Plans["power"].Items); n != 2 { - t.Fatalf("the contributor's plan names %d items", n) - } -} - -// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped -// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as -// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the -// collector is not handed the controller fills the files; where it is, it names the items for the client, which -// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files). -func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) { - read := func(name string) string { - b, err := os.ReadFile(filepath.Join("testdata", "bars", name)) - if err != nil { - t.Fatal(err) - } - return string(b) - } - collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"}, - {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}` - power := `{"module":"power","version":"1","moved":true, - "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, - {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` - for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true}, - "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} { - bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")})) - blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")})) - r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account, r.Capabilities = "op", caps - // Not handed: the controller fills them. - for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} { - if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") { - t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml")) - } - } - // Handed: the plan names the items the machine's capabilities allow, for the client to fill. - c, _ := composeWith(t, r, handingTo("store:5000")) - var names []string - for _, it := range c.Plans["power"].Items { - names = append(names, it.Name) - } - want := []string{} - if caps["battery"] { - want = append(want, "battery") - } - if caps["power-meter"] { - want = append(want, "power-draw") - } - if !reflect.DeepEqual(append([]string{}, names...), want) { - t.Errorf("%s: the plan names %v, want %v", name, names, want) - } - } -} diff --git a/internal/catalogue/collections_l2_test.go b/internal/catalogue/collections_l2_test.go deleted file mode 100644 index c16c4a0d..00000000 --- a/internal/catalogue/collections_l2_test.go +++ /dev/null @@ -1,60 +0,0 @@ -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/internal/catalogue/collections_l3_test.go b/internal/catalogue/collections_l3_test.go deleted file mode 100644 index 4c687a45..00000000 --- a/internal/catalogue/collections_l3_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package catalogue - -import ( - "reflect" - "strings" - "testing" -) - -// An item may carry attributes and a capability, a module may give several items to one collection, and a -// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose -// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a -// selected moved item into an old placeholder exactly as the contribution it replaces. - -const ( - barCollector = `{"module":"bars","version":"1","moved":true, - "collections":[{"name":"blocks","comment":"#"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` - barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}" - barContribute = `{"module":"power","version":"1","moved":true, - "items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"}, - {"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` -) - -func blocksOn(t *testing.T, caps map[string]bool) Composed { - t.Helper() - bars := held(t, barCollector, builtWith("bars", map[string]string{"bars": barsText})) - power := held(t, barContribute, builtWith("power", map[string]string{"battery": "B\n", "clock": "C\n"})) - r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account, r.Capabilities = "op", caps - c, _ := composeWith(t, r, handingTo("store:5000")) - return c -} - -func TestAnItemWhoseCapabilityTheMachineLacksIsLeftOutOfItsPlan(t *testing.T) { - with := blocksOn(t, map[string]bool{"battery": true}).Plans["power"] - want := []PlanItem{ - {Collection: "blocks", Name: "battery", Template: "battery", - Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, - {Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}}, - } - if !reflect.DeepEqual(with.Items, want) { - t.Fatalf("with the capability the plan carries %+v", with.Items) - } - without := blocksOn(t, map[string]bool{"seat": true}).Plans["power"] - if !reflect.DeepEqual(without.Items, want[1:]) { - t.Fatalf("without it the plan carries %+v", without.Items) - } -} - -func TestACollectorSelectingByAttributesCollectsTheCollectionOnce(t *testing.T) { - bars := blocksOn(t, nil).Plans["bars"] - if !reflect.DeepEqual(bars.Collections, []PlanCollection{{Name: "blocks", Comment: "#"}}) { - t.Fatalf("the plan collects %+v", bars.Collections) - } -} - -func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) { - for placeholder, want := range map[string]string{ - "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar - "${collected:blocks:bar=top,bar=bottom}": "names bar twice", - "${collected:blocks:bar=}": "is not =", - "${collected:other:bar=top}": `does not collect "other"`, - } { - raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) - raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) - raw = strings.Replace(raw, `"moved":true,`, "", 1) - m, err := ParseManifest([]byte(raw)) - if err == nil { - _, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{}) - } - if want == "" { - continue - } - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: %v, want %q", placeholder, err, want) - } - } -} - -func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { - item := func(extra string) string { - return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + - `"items":[` + extra + `]}` - } - for items, want := range map[string]string{ - `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name", - `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice", - `{"collection":"c","name":"Big","template":"files/a"}`: "is not a name", - `{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99", - `{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value", - `{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports", - `{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left", - `{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`, - `{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour", - } { - _, err := ParseManifest([]byte(item(items))) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: %v, want %q", items, err, want) - } - } - ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`) - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Fatal(err) - } -} - -// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item -// name) and with the same lines as the contributions they replace. -func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) { - piece := func(order, command string) string { - return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n" - } - asOld := Manifest{Module: "power", Contributions: []SeatContribution{ - block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`), - block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`), - block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`), - }} - asItems := Manifest{Module: "power", Moved: true, Items: []Item{ - {Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"), - Attributes: map[string]string{"bar": "bottom", "place": "status"}}, - {Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"), - Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, - {Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"), - Attributes: map[string]string{"bar": "top", "place": "resources"}}, - {Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery", - Attributes: map[string]string{"bar": "bottom", "place": "status"}}, - }} - other := Manifest{Module: "another", Contributions: []SeatContribution{ - block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}} - for _, caps := range []map[string]bool{nil, {"battery": true}} { - was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, - Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom") - is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, - Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom") - // The old form puts the contributions of a module under one line naming it and the item form puts one under - // each item: the same pieces in the same order, otherwise the same bytes. - want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") + - "# power\n" + piece("", "one") - if caps["battery"] { - want += "# power\nx\n" - } - want += "own-last\n" - if is != want { - t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want) - } - if !strings.Contains(was, piece("", "two")) { - t.Errorf("the contributions made\n%s", was) - } - } - if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) { - t.Errorf("the top bar renders\n%s", got) - } -} diff --git a/internal/catalogue/collections_test.go b/internal/catalogue/collections_test.go deleted file mode 100644 index c9aaf1b9..00000000 --- a/internal/catalogue/collections_test.go +++ /dev/null @@ -1,179 +0,0 @@ -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/internal/catalogue/computed_test.go b/internal/catalogue/computed_test.go deleted file mode 100644 index ccdd1986..00000000 --- a/internal/catalogue/computed_test.go +++ /dev/null @@ -1,205 +0,0 @@ -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/internal/catalogue/config_rename_test.go b/internal/catalogue/config_rename_test.go deleted file mode 100644 index 35ed21aa..00000000 --- a/internal/catalogue/config_rename_test.go +++ /dev/null @@ -1,185 +0,0 @@ -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/internal/catalogue/config_test.go b/internal/catalogue/config_test.go deleted file mode 100644 index fd7f55ee..00000000 --- a/internal/catalogue/config_test.go +++ /dev/null @@ -1,484 +0,0 @@ -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/internal/catalogue/consumes_start_test.go b/internal/catalogue/consumes_start_test.go deleted file mode 100644 index 33747922..00000000 --- a/internal/catalogue/consumes_start_test.go +++ /dev/null @@ -1,24 +0,0 @@ -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/internal/catalogue/contributes_test.go b/internal/catalogue/contributes_test.go deleted file mode 100644 index beb6ab6e..00000000 --- a/internal/catalogue/contributes_test.go +++ /dev/null @@ -1,412 +0,0 @@ -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/internal/catalogue/contribution_dependencies_test.go b/internal/catalogue/contribution_dependencies_test.go deleted file mode 100644 index 91e9bc03..00000000 --- a/internal/catalogue/contribution_dependencies_test.go +++ /dev/null @@ -1,136 +0,0 @@ -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/internal/catalogue/controller_is_a_process_test.go b/internal/catalogue/controller_is_a_process_test.go deleted file mode 100644 index f5dc530a..00000000 --- a/internal/catalogue/controller_is_a_process_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "fmt" - "os" - "strings" - "testing" -) - -// novox/hq issue 213: the controller is a Go program and was the one piece of the mesh's own Go -// code still shipped as an image (ADR 0188 §1). Its own manifest, composed for the machine that runs -// it, is a Go bundle run by the host as a process — and no container. -func TestTheControllerIsAProcessAndNoContainer(t *testing.T) { - raw, err := os.ReadFile("../../module.json") - if err != nil { - t.Fatal(err) - } - m, err := ParseManifest(raw) - if err != nil { - t.Fatalf("the controller's own manifest does not parse:\n%v", err) - } - if m.Build == nil || len(m.Build.Artifacts) != 1 { - t.Fatalf("the controller builds %+v; it is one bundle", m.Build) - } - a := m.Build.Artifacts[0] - if a.Kind != ArtifactBundle || a.Language != "go" || a.System == "" || BinaryOf(a) != "mesh-controller" { - t.Fatalf("the controller's artifact is %+v, not a Go bundle naming its system and binary", a) - } - for _, c := range m.Capabilities { - if c == "container-runtime" { - t.Error("the controller still requires a container runtime on its machine") - } - } - - digest := "sha256:" + strings.Repeat("c", 64) - control, err := m.Resolve([]Built{{Name: a.Name, Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "mesh-controller/" + a.Name + "@" + digest, Digest: digest}}) - if err != nil { - t.Fatal(err) - } - // The node's runtime does not launch it: it serves its seat's verbs itself. - if loads := control.Bundles[0].Loads; len(loads) != 0 { - t.Errorf("the node's runtime would launch the controller as a tools bundle: %v", loads) - } - - needed := map[string]map[string]string{"mesh-controller": {}} - for name := range m.OwnSecrets { - needed["mesh-controller"][name] = "sealed-" + name - } - out, err := Resolution{Node: "anchor", Modules: []Manifest{control}}.Declaration(Rendering{ - Needed: needed, ArtifactStore: "anchor.internal:5100", - Seats: map[string]map[int]int{"mesh-store": {5432: 6852}}, - }) - if err != nil { - t.Fatalf("the controller does not compose: %v", err) - } - - var process, step map[string]any - account, firstSecret := -1, -1 - for i, r := range out { - switch { - case r["type"] == "container": - t.Errorf("the controller's declaration still runs a container: %v", r) - case r["id"] == "mesh-controller.controller": - process = r - case r["id"] == "mesh-controller.controller-prepare": - step = r - if process != nil { - t.Error("the controller's preparation is placed after the process it prepares for") - } - case r["type"] == "user" && r["name"] == "mesh-controller": - account = i - case strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && firstSecret < 0: - firstSecret = i - } - } - if process == nil { - t.Fatalf("the controller's process is not in its declaration: %v", out) - } - if run, _ := json.Marshal(process["run"]); string(run) != `["./mesh-controller","serve"]` { - t.Errorf("the controller is run as %s, not its own bundle's binary", run) - } - if process["source"] != "anchor.internal:5100/mesh-controller/"+a.Name+"@"+digest || process["digest"] != digest { - t.Errorf("the controller's bundle is fetched from %v (%v)", process["source"], process["digest"]) - } - // The user: an account the host declares, which owns what the process reads. - if process["user"] != "mesh-controller" || account < 0 { - t.Errorf("the controller runs as %v, and the account declared is at %d", process["user"], account) - } - if firstSecret >= 0 && account > firstSecret { - t.Error("the controller's secrets are written before the account they belong to exists") - } - for _, r := range out { - if strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && r["owner"] != "mesh-controller" { - t.Errorf("%v belongs to %v, which the controller's process cannot read", r["id"], r["owner"]) - } - } - if dir := fileNamed(out, "mesh-controller.mesh-state"); dir == nil || dir["owner"] != "mesh-controller" { - t.Errorf("the controller's state directory is not its account's to enter: %v", dir) - } - // Each mount became a path the process reads: nothing it is told is a path inside a container. - state := fmt.Sprint(fileNamed(out, "mesh-controller.mesh-state")["path"]) - env, _ := process["env"].(map[string]any) - for key, value := range env { - v := fmt.Sprint(value) - if strings.HasPrefix(v, "/run/secrets") || strings.HasPrefix(v, "/broker-tls") { - t.Errorf("%s=%s is a path inside the container the controller no longer runs in", key, v) - } - if strings.HasSuffix(key, "_FILE") && !strings.HasPrefix(v, state+"/") { - t.Errorf("%s=%s is not one of the files the mesh places for it", key, v) - } - } - if env["MESH_BROKER_CERTIFICATE"] != "/var/lib/mesh-broker-tls/tls.crt" { - t.Errorf("the controller reads the broker's certificate from %v", env["MESH_BROKER_CERTIFICATE"]) - } - if env["MESH_STORE_INVENTORY_PORT"] != "6852" { - t.Errorf("the controller is told the store is on %v; the node put it on 6852", env["MESH_STORE_INVENTORY_PORT"]) - } - // The handover: the container it ran as goes only once this is running. - if got, _ := json.Marshal(process["replaces"]); string(got) != `["mesh-controller.server"]` { - t.Errorf("the controller's process replaces %s, not the container it ran as", got) - } - // And its state is prepared first, by the same program as the same account. - if step == nil || step["run-once"] != true || step["user"] != "mesh-controller" { - t.Fatalf("the controller's preparation is %v", step) - } - if run, _ := json.Marshal(step["run"]); string(run) != `["./mesh-controller","prepare"]` { - t.Errorf("the controller's preparation runs %s", run) - } -} diff --git a/internal/catalogue/core_credentials_test.go b/internal/catalogue/core_credentials_test.go deleted file mode 100644 index 1c123f3f..00000000 --- a/internal/catalogue/core_credentials_test.go +++ /dev/null @@ -1,107 +0,0 @@ -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/internal/catalogue/data_test.go b/internal/catalogue/data_test.go deleted file mode 100644 index 9511cdaf..00000000 --- a/internal/catalogue/data_test.go +++ /dev/null @@ -1,227 +0,0 @@ -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/internal/catalogue/declaration_test.go b/internal/catalogue/declaration_test.go deleted file mode 100644 index 23b1caaf..00000000 --- a/internal/catalogue/declaration_test.go +++ /dev/null @@ -1,80 +0,0 @@ -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/internal/catalogue/delivery_seat_test.go b/internal/catalogue/delivery_seat_test.go deleted file mode 100644 index 7d3a6193..00000000 --- a/internal/catalogue/delivery_seat_test.go +++ /dev/null @@ -1,164 +0,0 @@ -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/internal/catalogue/derived_for_consumer_test.go b/internal/catalogue/derived_for_consumer_test.go deleted file mode 100644 index 8d5e9734..00000000 --- a/internal/catalogue/derived_for_consumer_test.go +++ /dev/null @@ -1,346 +0,0 @@ -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/internal/catalogue/dir_into_test.go b/internal/catalogue/dir_into_test.go deleted file mode 100644 index ee7a96e2..00000000 --- a/internal/catalogue/dir_into_test.go +++ /dev/null @@ -1,325 +0,0 @@ -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/internal/catalogue/domain_test.go b/internal/catalogue/domain_test.go deleted file mode 100644 index 967985b6..00000000 --- a/internal/catalogue/domain_test.go +++ /dev/null @@ -1,134 +0,0 @@ -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/internal/catalogue/endpoint_test.go b/internal/catalogue/endpoint_test.go deleted file mode 100644 index d05f0407..00000000 --- a/internal/catalogue/endpoint_test.go +++ /dev/null @@ -1,205 +0,0 @@ -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/internal/catalogue/endpoints_setting_test.go b/internal/catalogue/endpoints_setting_test.go deleted file mode 100644 index bfab88cb..00000000 --- a/internal/catalogue/endpoints_setting_test.go +++ /dev/null @@ -1,140 +0,0 @@ -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/internal/catalogue/engine_guard_test.go b/internal/catalogue/engine_guard_test.go deleted file mode 100644 index 85b75970..00000000 --- a/internal/catalogue/engine_guard_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package catalogue - -import ( - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "slices" - "strconv" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue -// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone -// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists -// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor -// passes what it refuses by path alone. -func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) { - file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go") - parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) - if err != nil { - t.Fatalf("the node-engine's guard does not parse: %v", err) - } - lists := map[string][]string{} - consts := map[string]string{} - ast.Inspect(parsed, func(n ast.Node) bool { - spec, ok := n.(*ast.ValueSpec) - if !ok { - return true - } - for i, name := range spec.Names { - if i >= len(spec.Values) { - continue - } - switch v := spec.Values[i].(type) { - case *ast.CompositeLit: - for _, elt := range v.Elts { - if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING { - s, _ := strconv.Unquote(lit.Value) - lists[name.Name] = append(lists[name.Name], s) - } - } - case *ast.BasicLit: - if v.Kind == token.STRING { - consts[name.Name], _ = strconv.Unquote(v.Value) - } - } - } - return true - }) - for name, ours := range map[string][]string{ - "protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees, - } { - theirs := lists[name] - if len(theirs) == 0 { - t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name) - continue - } - a, b := slices.Clone(ours), slices.Clone(theirs) - slices.Sort(a) - slices.Sort(b) - if !slices.Equal(a, b) { - t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b) - } - } - if consts["engineModule"] != engineModule { - t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule) - } -} diff --git a/internal/catalogue/environment_into_integration_test.go b/internal/catalogue/environment_into_integration_test.go deleted file mode 100644 index b41864ec..00000000 --- a/internal/catalogue/environment_into_integration_test.go +++ /dev/null @@ -1,91 +0,0 @@ -//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/internal/catalogue/environment_into_test.go b/internal/catalogue/environment_into_test.go deleted file mode 100644 index ca112e9b..00000000 --- a/internal/catalogue/environment_into_test.go +++ /dev/null @@ -1,424 +0,0 @@ -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/internal/catalogue/environment_items_test.go b/internal/catalogue/environment_items_test.go deleted file mode 100644 index cd4c93bf..00000000 --- a/internal/catalogue/environment_items_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package catalogue - -import ( - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -// The session environment is a collection of typed items (design 57 L3, issue 795): a variable is an item with -// kind=variable and name=, a PATH entry one with kind=path and at=start|end, each a template whose text is the -// value or the entry. Where node-env is not handed the controller renders the items as the old form was rendered; -// where it is, it names the collection for the client, which fills the same files (mesh-catalog's provisioner client -// holds that half against the same golden files). testdata/environment holds the environment of the catalogue's five -// contributors as the old form composed it, with all five and with zsh alone. - -type envDecl struct{ module, item, name, value string } - -func environmentOfTheCatalogue() []envDecl { - return []envDecl{ - {"adwaita", "gtk-theme", "GTK_THEME", "Adwaita:dark"}, - {"adwaita", "gtk2-rc-files", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"}, - {"adwaita", "qt-qpa-platformtheme", "QT_QPA_PLATFORMTHEME", "qt6ct"}, - {"adwaita", "qt-style-override", "QT_STYLE_OVERRIDE", "Fusion"}, - {"adwaita", "qt-select", "QT_SELECT", "6"}, - {"adwaita", "xcursor-theme", "XCURSOR_THEME", "Adwaita"}, - {"adwaita", "xcursor-size", "XCURSOR_SIZE", "24"}, - {"clipmenu", "cm-selections", "CM_SELECTIONS", "clipboard"}, - {"clipmenu", "cm-max-clips", "CM_MAX_CLIPS", "500"}, - {"clipmenu", "cm-histlength", "CM_HISTLENGTH", "15"}, - {"i3", "xdg-current-desktop", "XDG_CURRENT_DESKTOP", "i3"}, - {"i3", "xdg-session-desktop", "XDG_SESSION_DESKTOP", "i3"}, - {"xterm", "terminal", "TERMINAL", "xterm"}, - {"zsh", "editor", "EDITOR", "vim"}, - {"zsh", "visual", "VISUAL", "vim"}, - {"zsh", "xdg-config-home", "XDG_CONFIG_HOME", "${machine:account-home}/.config"}, - {"zsh", "path-local-bin", "path:10", "${machine:account-home}/.local/bin"}, - {"zsh", "path-scripts", "path:20", "${machine:account-home}/scripts"}, - {"zsh", "path-scripts-bin", "path:30", "${machine:account-home}/scripts/bin"}, - } -} - -const environmentCollector = `{"module":"node-env","version":"1","moved":true, - "claims":[{"name":"node-environment","scope":"node"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"posix","type":"file","path":"/home/op/.config/mesh/environment.sh","template":"files/posix"}, - {"id":"systemd","type":"file","path":"/home/op/.config/environment.d/50-mesh.conf","template":"files/systemd"}]}` - -// nodeEnvWith is node-env, its file placing the environment as plan says, with the declared contributors' items. -func nodeEnvWith(t *testing.T, decls []envDecl, plan string) Resolution { - t.Helper() - collector := held(t, environmentCollector, builtWith("node-env", map[string]string{ - "posix": "# header\n" + plan, "systemd": "${environment:systemd}"})) - mods := []Manifest{collector} - order := []string{"node-env"} - byModule := map[string][]envDecl{} - var modules []string - for _, d := range decls { - if byModule[d.module] == nil { - modules = append(modules, d.module) - } - byModule[d.module] = append(byModule[d.module], d) - } - for _, name := range modules { - var items []string - texts := map[string]string{} - for _, d := range byModule[name] { - attrs := fmt.Sprintf(`{"kind":"variable","name":%q}`, d.name) - if order, isPath := strings.CutPrefix(d.name, "path:"); isPath { - attrs = fmt.Sprintf(`{"kind":"path","at":"start","order":%q}`, order) - } - items = append(items, fmt.Sprintf(`{"collection":"environment","name":%q,"attributes":%s,"template":"files/env-%s"}`, - d.item, attrs, d.item)) - texts["env-"+d.item] = d.value + "\n" - } - raw := fmt.Sprintf(`{"module":%q,"version":"1","moved":true,"items":[%s], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, name, strings.Join(items, ",")) - mods = append(mods, held(t, raw, builtWith(name, texts))) - order = append(order, name) - } - r, err := Resolve(shelf(mods...), order, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account = "op" - return r -} - -func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) { - read := func(name string) string { - b, err := os.ReadFile(filepath.Join("testdata", "environment", name)) - if err != nil { - t.Fatal(err) - } - return string(b) - } - for name, only := range map[string]string{"all": "", "zsh": "zsh"} { - var decls []envDecl - for _, d := range environmentOfTheCatalogue() { - if only == "" || d.module == only { - decls = append(decls, d) - } - } - // Named as the collector names it, and as the old placeholder does. - for _, plan := range []string{"${collected:environment:posix}", "${environment:posix}"} { - r := nodeEnvWith(t, decls, plan) - // Not handed: the controller renders each moved contributor's items as the old form was rendered. - if got, want := composedFile(t, r, "node-env.posix"), "# header\n"+read(name+".posix"); got != want { - t.Errorf("%s, %s, not handed, the POSIX file is\n%s\nwant\n%s", name, plan, got, want) - } - if got, want := composedFile(t, r, "node-env.systemd"), read(name+".systemd"); got != want { - t.Errorf("%s, %s, not handed, the environment.d file is\n%s\nwant\n%s", name, plan, got, want) - } - // Handed: one collection, named for the client; each contributor's plan names its items. - c, _ := composeWith(t, r, handingTo("store:5000")) - if got := c.Plans["node-env"].Collections; !reflect.DeepEqual(got, []PlanCollection{{Name: "environment", Comment: "#"}}) { - t.Errorf("%s: the plan collects %+v", name, got) - } - items, got := len(decls), 0 - for m := range c.Plans { - got += len(c.Plans[m].Items) - } - if got != items { - t.Errorf("%s: the plans name %d items, want %d", name, got, items) - } - } - } -} - -func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) { - item := func(attrs map[string]string) []string { - return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs}) - } - for _, ok := range []map[string]string{ - {"kind": "variable", "name": "GTK_THEME"}, - {"kind": "path", "at": "end", "order": "10"}, - } { - if p := item(ok); len(p) != 0 { - t.Errorf("%v is refused: %v", ok, p) - } - } - for _, bad := range []map[string]string{ - nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"}, - {"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"}, - {"kind": "variable", "name": "A", "color": "red"}, - } { - if p := item(bad); len(p) == 0 { - t.Errorf("%v is taken", bad) - } - } - if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") || - attributeFits("environment", "kind", "Variable") { - t.Error("an upper-case name is the environment's name alone") - } - // A module that sets a variable twice, in two modules, is refused as the old form refused it. - a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n", - Attributes: map[string]string{"kind": "variable", "name": "X"}}}} - b := Manifest{Module: "b", Items: []Item{{Collection: "environment", Content: "2\n", - Attributes: map[string]string{"kind": "variable", "name": "X"}}}} - if err := variablesSetOnce([]Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") { - t.Errorf("two modules setting X: %v", err) - } -} diff --git a/internal/catalogue/files_test.go b/internal/catalogue/files_test.go deleted file mode 100644 index 63f69233..00000000 --- a/internal/catalogue/files_test.go +++ /dev/null @@ -1,111 +0,0 @@ -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/internal/catalogue/filtering_foundation_test.go b/internal/catalogue/filtering_foundation_test.go deleted file mode 100644 index 60056513..00000000 --- a/internal/catalogue/filtering_foundation_test.go +++ /dev/null @@ -1,58 +0,0 @@ -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/internal/catalogue/filtering_test.go b/internal/catalogue/filtering_test.go deleted file mode 100644 index 11cb514d..00000000 --- a/internal/catalogue/filtering_test.go +++ /dev/null @@ -1,920 +0,0 @@ -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/internal/catalogue/foundation_manifests_test.go b/internal/catalogue/foundation_manifests_test.go deleted file mode 100644 index 88e64de8..00000000 --- a/internal/catalogue/foundation_manifests_test.go +++ /dev/null @@ -1,368 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it, -// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the -// real parser (novox/hq ADR 0100): -// the store and the broker say which of their ports the mesh guards on an adopted node, and the -// filter module loads its table through a unit of its own whose stop deletes only that table. -// moduleTree is where a module is read from: this repository's own modules/ when it lives whole here -// (novox/hq ADR 0420 §1, issue 1019: build-agent and route-proxy), with this repository as the tree root; -// otherwise the catalogue beside, with the catalogue as the root. -func moduleTree(t *testing.T, module string) (root, dir string) { - t.Helper() - own := filepath.Join("..", "..", "modules", module) - if _, err := os.Stat(filepath.Join(own, "module.json")); err == nil { - return filepath.Join("..", ".."), own - } - return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module) -} - -func catalogueManifest(t *testing.T, module string) Manifest { - t.Helper() - root, dir := moduleTree(t, module) - m, err := ReadTreeManifest(root, dir) - if err != nil { - t.Fatalf("%s does not parse:\n%v", module, err) - } - return m -} - -func TestTheStoreAndTheBrokerSayWhatTheMeshGuards(t *testing.T) { - if got := catalogueManifest(t, "postgres").Guards; !reflect.DeepEqual(got, []int{5432}) { - t.Errorf("postgres guards %v; the store's port must be refused from outside", got) - } - // The bus's monitoring port, not its client port: a node reaches the bus, nobody outside - // reads its state (novox/hq ADR 0131 — the broker that guarded 15672 has left the catalogue). - if got := catalogueManifest(t, "nats").Guards; !reflect.DeepEqual(got, []int{8222}) { - t.Errorf("nats guards %v; the monitoring port must be refused from outside", got) - } -} - -func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) { - m := catalogueManifest(t, "nftables") - var unit, stock, load map[string]any - for _, r := range m.Resources { - switch r["id"] { - case "unit": - unit = r - case "stock-unit-stop": - stock = r - case "load": - load = r - } - } - if load == nil || load["unit"] != "mesh-filter.service" { - t.Fatalf("the filter is not loaded by its own unit: %v", load) - } - content, _ := unit["content"].(string) - if unit == nil || unit["path"] != "/etc/systemd/system/mesh-filter.service" { - t.Fatalf("the filter's unit is not written: %v", unit) - } - if strings.Contains(content, "flush") { - t.Fatalf("stopping the filter flushes the whole ruleset — the runtime's and the found "+ - "firewall's with it:\n%s", content) - } - if !strings.Contains(content, "ExecStop=nft delete table inet mesh\n") || - !strings.Contains(content, "ExecStart=nft -f "+m.Filtering.Into+"\n") { - t.Fatalf("the unit does not load the computed rule set and delete only its own table:\n%s", - content) - } - // A node converged before the filter had its own unit still has the stock nftables.service - // enabled, whose stop flushes the whole ruleset: a drop-in makes it delete only the mesh's - // table, and the load is restarted on it so the host reloads units and the drop-in is read. - if stock == nil || stock["path"] != "/etc/systemd/system/nftables.service.d/mesh.conf" || - !strings.HasSuffix(fmt.Sprint(stock["content"]), - "[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n") { - t.Fatalf("the stock unit's stop is not replaced with deleting the mesh's table: %v", stock) - } - // A changed rule set is RELOADED — ExecReload replaces the table in one `nft -f`, so the node - // is never unfiltered — and only the units themselves restart it, which is the one change a - // reload cannot carry. - if !reflect.DeepEqual(load["reload-on"], []any{"filtering"}) { - t.Fatalf("the filter is restarted rather than reloaded when its rules change, leaving the "+ - "node unfiltered in between: %v", load) - } - if !reflect.DeepEqual(load["restart-on"], []any{"unit", "stock-unit-stop"}) { - t.Fatalf("the filter is not restarted when its unit or the stock unit's drop-in changes: %v", - load["restart-on"]) - } -} - -// The package registry's port is the node's, like every other foundation port (novox/hq -// 04-ISSUES/085, ADR 0100). The forge is reached through what it says it serves, and consumers — -// the builder among them — are told that, rather than carrying a number of their own. - -func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) { - forge := catalogueManifest(t, "gitea") - - // The catalogue's number is a default and the node's setting moves it. - given, err := GivenPorts(forge, []Layer{{From: "anchor", - Placement: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}}) - if err != nil { - t.Fatalf("the forge's port cannot be given on a node: %v", err) - } - if given[3000] != 3100 { - t.Fatalf("the forge was given %v", given) - } - - // And every consumer of the package registry is told where the machine actually put it, - // because that is read from what the forge serves rather than written in the consumer. - if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 { - t.Errorf("the package registry is served on %v, not the port this node gave it", got) - } - if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) { - t.Errorf("without a setting the forge serves %v, not the catalogue's port", got) - } - // And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason: - // a build composes the URL from what the forge serves, so a given port is a followed port. - if got := ServedOn(forge, "git", given)["port"]; got != 3100 { - t.Errorf("git is served on %v, not the port this node gave the forge", got) - } -} - -// **The builder requires the registry the npm seat delivers, and carries no binding of its own.** -// -// It used to carry a hand-written binding because nothing provided a package registry to resolve -// one from at genesis. The catalogue now requires it like any consumer, and ADR 0110 makes the -// seat's holder the answer when more than one module provides it — so a carried copy would be a -// second answer to the same question, free to drift from the first. Asserted gone, not merely -// unused. -// -// The builder is the build-agent on every machine since novox/hq ADR 0190; this read the retired -// `builder` and, finding no manifest, skipped unseen from then until novox/hq issue 432. -func TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers(t *testing.T) { - builder := catalogueManifest(t, "build-agent") - seat, _ := SeatNamed("npm-package-registry") - var requires bool - for _, r := range builder.Requires { - requires = requires || r == seat.Delivers - } - if !requires { - t.Fatalf("the builder does not require %q: %v", seat.Delivers, builder.Requires) - } - if builder.Binds[seat.Delivers] == "" { - t.Errorf("the builder is not told where the registry is: binds %v", builder.Binds) - } - for _, r := range builder.Resources { - if fmt.Sprint(r["id"]) == "package-binding" { - t.Fatal("the builder carries its own package binding beside the one the mesh resolves") - } - } -} - -// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser — -// which refuses a delivering seat claimed by a module that does not provide what it delivers. -func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) { - forge := catalogueManifest(t, "gitea") - holds := map[string]bool{} - for _, c := range forge.Claims { - holds[c.Name] = true - } - for _, seat := range []string{"npm-package-registry", "git"} { - if !holds[seat] { - t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims) - } - } - git := ServedOn(forge, "git", nil) - if git["scheme"] != "http" || git["port"] != float64(3000) { - t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git) - } - npm := ServedOn(forge, "npm-package-registry", nil) - if npm["npm-path"] != "/api/packages/novox/npm/" { - t.Errorf("gitea no longer says where its npm registry is: %v", npm) - } -} - -// **And the forge's own address follows it**, composed from the manifest in the catalogue beside -// this checkout (novox/hq 04-ISSUES/088). -// -// The forge is reached a third way that neither test above covers: by its own code, over the -// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the -// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is -// wrong on every node whose assignment differs, and wrong for a second reason on a node given the -// port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves -// in an `env` at all is a declaration, not a manifest. -func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { - forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea"))) - if err != nil { - t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) - } - r := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org", - Modules: []Manifest{forge, theRuntime(t)}, Needs: []Needed{ - {Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1", - Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"}, - {Name: "route", For: "gitea", From: "anchor"}, - {Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"}, - {Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"}, - }} - - // The number this node was given for the forge — the one the machine it is about to run on - // already publishes. - out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}}, - Given: map[string]map[int]int{"gitea": {3000: 2999}}, - }) - if err != nil { - t.Fatalf("the forge does not compose: %v", err) - } - - // What the machine publishes, and what the forge's sidecar is told to dial: one number. - server := fileNamed(out, "gitea.server") - if server == nil { - t.Fatalf("the forge's own container is not in the declaration: %v", out) - } - if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") { - t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"]) - } - // The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there. - runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) - if runtime == nil { - t.Fatalf("the node's runtime is not in the declaration: %v", ids(out)) - } - env, _ := runtime["env"].(map[string]string) - var given map[string]map[string]string - if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil { - t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv]) - } - if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" { - t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+ - "whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"]) - } -} - -// gitea's own sshd is unmodified — the module's own internal port is 22, the number in -// `listens`, the same convention every other module in the catalogue uses (its internal port, -// not an invented identity). Composed from the manifest in the catalogue beside this checkout, -// because what the mesh publishes is a fact about what the module actually writes. -func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any { - t.Helper() - forge := catalogueManifest(t, "gitea") - resolved, err := forge.Resolve(forgeBuilt(forge)) - if err != nil { - t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) - } - r := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org", - Modules: []Manifest{resolved, theRuntime(t)}, Needs: []Needed{ - {Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1", - Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"}, - {Name: "route", For: "gitea", From: "anchor"}, - {Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"}, - {Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"}, - }} - givenPorts := map[int]int{3000: 3000} - for k, v := range given { - givenPorts[k] = v - } - out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}}, - Ports: map[string]map[int]int{"gitea": givenPorts}, - Given: map[string]map[int]int{"gitea": given}, - }) - if err != nil { - t.Fatalf("the forge does not compose: %v", err) - } - server := fileNamed(out, "gitea.server") - if server == nil { - t.Fatalf("the forge's own container is not in the declaration: %v", out) - } - return server -} - -// **The forge publishes ssh at the mesh's own fixed convention by default** (novox/hq ADR 0100). -// -// `222` is the mesh's own public convention for the forge's ssh, written directly in the -// manifest's `ports` — every node the forge has run on used the same number, so it needs no -// per-node setting to reach it. -func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) { - forge := catalogueManifest(t, "gitea") - // Nothing was given — no node moved this port — which is the ordinary answer: the mesh only - // reports what a setting moved, and the manifest's own `222:22` needs no move to be reached. - given, err := GivenPorts(forge, nil) - if err != nil { - t.Fatalf("the forge's ssh port cannot be given on a node: %v", err) - } - if len(given) != 0 { - t.Fatalf("nothing moved the forge's ssh port, yet it was given %v", given) - } - server := declaredGiteaSsh(t, given) - if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") { - t.Fatalf("the forge is published on %v, not its own fixed convention", server["ports"]) - } -} - -// **A node whose predecessor served git on a different number can still be told to leave it -// there.** The setting names the port the module itself listens on — 22, gitea's own sshd, the -// same number `listens` uses — not the mesh's own default machine-side number, so moving it does -// not require guessing what the manifest happens to default to. -func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) { - forge := catalogueManifest(t, "gitea") - given, err := GivenPorts(forge, []Layer{{From: "anchor", - Placement: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}}) - if err != nil { - t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err) - } - if want := map[int]int{22: 9022}; !reflect.DeepEqual(given, want) { - t.Fatalf("the forge was given %v, and the setting named %v", given, want) - } - server := declaredGiteaSsh(t, given) - if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "9022:22") || - strings.Contains(published, "222:22") { - t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"]) - } -} - -// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the -// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third -// artifact does not break every forge test again. -func forgeBuilt(forge Manifest) []Built { - var out []Built - for _, a := range forge.Build.Artifacts { - out = append(out, Built{Name: a.Name, Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest}) - } - return out -} - -// catalogueResolved is a catalogue module as the mesh holds it once built: the manifest as written, resolved against -// what the build produced besides the archive its templates are packed into, which carries the text read back from the -// module's files/ directory as the real build reads it (novox/hq ADR 0349 §2) — so a module whose file text moved into -// templates composes with it, and one that did not is resolved as it was. -func catalogueResolved(t *testing.T, module string, built ...Built) Manifest { - t.Helper() - root, dir := moduleTree(t, module) - m, err := ReadTreeManifestWritten(root, dir) - if err != nil { - t.Fatalf("%s does not parse:\n%v", module, err) - } - if name := TemplatesArtifact(m); name != "" { - read, err := ReadTemplates(m, dir) - if err != nil { - t.Fatalf("%s: %v", module, err) - } - // By the path inside the archive, as the build reads it back, not by the path in the module's directory. - texts := map[string]string{} - for path, text := range read { - texts[strings.TrimPrefix(path, m.TemplatesDir()+"/")] = text - } - built = append(built, Built{Name: name, Kind: ArtifactArchive, - Reference: ArtifactStoreScheme + module + "/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest, - Texts: texts}) - } - held, err := m.Resolve(built) - if err != nil { - t.Fatalf("%s does not resolve against its own build: %v", module, err) - } - return held -} diff --git a/internal/catalogue/generated_collision_test.go b/internal/catalogue/generated_collision_test.go deleted file mode 100644 index 45fcabe8..00000000 --- a/internal/catalogue/generated_collision_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule -// every module is — no two modules on a machine declare one path, unit or name (a package may be declared by -// several, novox/hq ADR 0303). The -// private network once declared the container runtime's file and service beside the runtime's own -// module, and nothing refused it, because the collision check only ever saw catalogue manifests. - -func runtimesOwn() Manifest { - return Manifest{Module: "docker", Resources: []map[string]any{ - {"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "into": "json", - "content": `{"live-restore": true}`}, - {"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", - "reload-on": []any{"daemon"}}, - }} -} - -func TestAGeneratedResourceCollidingWithAModulesIsRefused(t *testing.T) { - r := Resolution{Node: "workstation", Modules: []Manifest{ - {Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")}, - runtimesOwn(), - }} - _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": reloading{}}}) - if err == nil { - t.Fatal("a generated resource declaring the runtime's file beside the runtime's module was accepted") - } - for _, want := range []string{"mesh-network", "docker", "/etc/docker/daemon.json", "docker.service"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not name %s: %v", want, err) - } - } -} - -func TestAGeneratedResourceBesideAModulesOwnIsComposed(t *testing.T) { - r := Resolution{Node: "workstation", Modules: []Manifest{ - {Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")}, - runtimesOwn(), - }} - gen := &fake{on: map[string]bool{"workstation": true}} - out, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}}) - if err != nil { - t.Fatalf("disjoint resources were refused: %v", err) - } - if fileNamed(out, "docker.daemon") == nil { - t.Fatalf("the runtime's own file is missing: %v", out) - } - // And a machine not on the network yet generates nothing, which collides with nothing. - if _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": &fake{}}}); err != nil { - t.Fatalf("a machine off the network was refused: %v", err) - } -} - -// The catalogue's container runtime module states the mesh's registry itself (ADR 0222). -func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) { - docker := catalogueManifest(t, "docker") - r := Resolution{Node: "workstation", Modules: []Manifest{docker}} - out, err := r.Declaration(Rendering{ - SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}, - }) - if err != nil { - t.Fatal(err) - } - daemon := fileNamed(out, "docker.daemon") - if daemon == nil || daemon["into"] != "json" { - t.Fatalf("the runtime's file is not written into: %v", daemon) - } - content, _ := daemon["content"].(string) - if !strings.Contains(content, `"insecure-registries": ["anchor.internal:5100"]`) || - !strings.Contains(content, `"live-restore": true`) { - t.Fatalf("the runtime's file says %q", content) - } - - out, err = r.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - if content, _ := fileNamed(out, "docker.daemon")["content"].(string); strings.Contains(content, "insecure-registries") { - t.Fatalf("with no store on the network, the runtime is told %q", content) - } -} diff --git a/internal/catalogue/genesis_image_test.go b/internal/catalogue/genesis_image_test.go deleted file mode 100644 index ad3f0f1e..00000000 --- a/internal/catalogue/genesis_image_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package catalogue - -import ( - "os" - "regexp" - "testing" -) - -// novox/hq issue 223: genesis raises the controller as a container built from this repository's own -// Dockerfile, with no build arguments — the manifest no longer builds an image, so nothing passes a -// base in. The Dockerfile's own default must therefore be a Go that builds this module, pinned by -// digest, and the replacement the manifest's process names must be the container genesis raises. -func TestGenesisCanBuildTheControllersImageAsItStands(t *testing.T) { - raw, err := os.ReadFile("../../Dockerfile") - if err != nil { - t.Fatal(err) - } - if !regexp.MustCompile(`(?m)^ARG GO_BASE=golang@sha256:[0-9a-f]{64}$`).Match(raw) { - t.Fatal("the Dockerfile's default Go base is not pinned by digest; genesis builds it with no arguments") - } - makefile, err := os.ReadFile("../../Makefile") - if err != nil { - t.Fatal(err) - } - pin := regexp.MustCompile(`golang@sha256:[0-9a-f]{64}`) - if string(pin.Find(raw)) != string(pin.Find(makefile)) { - t.Errorf("the Dockerfile and the Makefile build on different Go: %s, %s", pin.Find(raw), pin.Find(makefile)) - } -} diff --git a/internal/catalogue/genesis_registry_test.go b/internal/catalogue/genesis_registry_test.go deleted file mode 100644 index 2139c8af..00000000 --- a/internal/catalogue/genesis_registry_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Genesis assigns the build agent before the package registry exists (novox/hq ADR 0336, issue 121), -// judged over the real catalogue. Genesis's step 13 was refused with "nothing provides -// \"npm-package-registry\", wanted by build-agent": the forge genesis raises at its packages step is a -// container, not the gitea module, and provides nothing. - -// agentBuilt is the build agent as the mesh holds it once built: its image pinned. -func agentBuilt(t *testing.T) Manifest { - t.Helper() - return catalogueResolved(t, "build-agent", Built{Name: "server", Kind: ArtifactImage, - Reference: "mesh-builder@sha256:" + strings.Repeat("a", 64)}) -} - -func TestGenesisAssignsTheBuildAgentWithNothingProvidingTheRegistry(t *testing.T) { - agent := agentBuilt(t) - if !agent.IsOptional("npm-package-registry") { - t.Fatalf("the build agent's npm-package-registry is hard (ADR 0336 §1): %v", agent.Requires) - } - if agent.IsOptional("artifact-store") { - t.Fatal("the build agent's artifact-store is optional; it stays hard") - } - store := map[string][]Provider{"artifact-store": { - {Node: "anchor", At: "anchor.internal", Module: "distribution", Serves: map[string]any{"port": float64(5000)}}}} - books := shelf(agent, catalogueManifest(t, "gitea"), catalogueManifest(t, "distribution")) - - // Genesis's node at step 13: the store answers, nothing provides the registry. - got, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: store}) - if err != nil { - t.Fatalf("assigning the build agent with no registry was refused: %v", err) - } - if have := strings.Join(names(got), " "); have != "build-agent" { - t.Fatalf("assigning the build agent put %s on the machine; nothing is installed for the registry", have) - } - if len(got.Absent) != 1 || got.Absent[0] != (Absent{Consumer: "build-agent", Requirement: "npm-package-registry"}) { - t.Fatalf("the registry is not listed unbound for the build agent: %+v", got.Absent) - } - out, err := got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000", - Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker"}}}) - if err != nil { - t.Fatalf("the build agent does not compose with the registry unbound: %v", err) - } - for _, id := range []string{"build-agent." + BoundID("npm-package-registry"), "build-agent." + SecretID("npm-package-registry"), - "build-agent." + NeedID("genesis-package-registry")} { - if fileNamed(out, id) != nil { - t.Errorf("%s was delivered with the registry unbound and no genesis document given", id) - } - } - - // The genesis document, once the installer seals it at the packages step (ADR 0336 §3). - out, err = got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000", - Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker", "genesis-package-registry": "sealed-genesis"}}}) - if err != nil { - t.Fatal(err) - } - doc := fileNamed(out, "build-agent."+NeedID("genesis-package-registry")) - if doc == nil || doc["sealed"] != "sealed-genesis" || !strings.HasSuffix(doc["path"].(string), "/genesis-package-registry.json") { - t.Fatalf("the genesis document is not delivered where the agent reads it: %v", doc) - } - - // The gitea module assigned: the requirement binds, with no act on the build agent (ADR 0336 §2). - registry := map[string][]Provider{"artifact-store": store["artifact-store"], "npm-package-registry": { - {Node: "anchor", At: "anchor.internal", Module: "gitea", - Serves: map[string]any{"port": float64(3000), "scheme": "http", "npm-path": "/api/packages/novox/npm/"}}}} - bound, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: registry}) - if err != nil { - t.Fatal(err) - } - if n := boundFrom(t, bound, "build-agent", "npm-package-registry"); n.Module != "gitea" { - t.Fatalf("bound to %s", n.Module) - } - if len(bound.Absent) != 0 { - t.Fatalf("the registry is still listed unbound once gitea is assigned: %+v", bound.Absent) - } -} diff --git a/internal/catalogue/go_bundle_test.go b/internal/catalogue/go_bundle_test.go deleted file mode 100644 index eb6cd2bb..00000000 --- a/internal/catalogue/go_bundle_test.go +++ /dev/null @@ -1,37 +0,0 @@ -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/internal/catalogue/grant_secret_owner_test.go b/internal/catalogue/grant_secret_owner_test.go deleted file mode 100644 index 5fdf7fe0..00000000 --- a/internal/catalogue/grant_secret_owner_test.go +++ /dev/null @@ -1,103 +0,0 @@ -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/internal/catalogue/graphical_session_test.go b/internal/catalogue/graphical_session_test.go deleted file mode 100644 index f412e246..00000000 --- a/internal/catalogue/graphical_session_test.go +++ /dev/null @@ -1,213 +0,0 @@ -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/internal/catalogue/handed_bound_test.go b/internal/catalogue/handed_bound_test.go deleted file mode 100644 index 526f4d29..00000000 --- a/internal/catalogue/handed_bound_test.go +++ /dev/null @@ -1,159 +0,0 @@ -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/internal/catalogue/handed_consumers_test.go b/internal/catalogue/handed_consumers_test.go deleted file mode 100644 index a93f6ead..00000000 --- a/internal/catalogue/handed_consumers_test.go +++ /dev/null @@ -1,134 +0,0 @@ -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/internal/catalogue/handed_merge_test.go b/internal/catalogue/handed_merge_test.go deleted file mode 100644 index 32faf965..00000000 --- a/internal/catalogue/handed_merge_test.go +++ /dev/null @@ -1,57 +0,0 @@ -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/internal/catalogue/handed_providers_test.go b/internal/catalogue/handed_providers_test.go deleted file mode 100644 index 22744f8a..00000000 --- a/internal/catalogue/handed_providers_test.go +++ /dev/null @@ -1,274 +0,0 @@ -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/internal/catalogue/handed_pulled_in_test.go b/internal/catalogue/handed_pulled_in_test.go deleted file mode 100644 index cfec741f..00000000 --- a/internal/catalogue/handed_pulled_in_test.go +++ /dev/null @@ -1,121 +0,0 @@ -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/internal/catalogue/handed_secrets_test.go b/internal/catalogue/handed_secrets_test.go deleted file mode 100644 index 5d06214e..00000000 --- a/internal/catalogue/handed_secrets_test.go +++ /dev/null @@ -1,252 +0,0 @@ -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/internal/catalogue/handed_test.go b/internal/catalogue/handed_test.go deleted file mode 100644 index dbe3bec3..00000000 --- a/internal/catalogue/handed_test.go +++ /dev/null @@ -1,188 +0,0 @@ -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/internal/catalogue/health_test.go b/internal/catalogue/health_test.go deleted file mode 100644 index c5a68de1..00000000 --- a/internal/catalogue/health_test.go +++ /dev/null @@ -1,217 +0,0 @@ -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/internal/catalogue/holdings_test.go b/internal/catalogue/holdings_test.go deleted file mode 100644 index 01594905..00000000 --- a/internal/catalogue/holdings_test.go +++ /dev/null @@ -1,194 +0,0 @@ -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/internal/catalogue/holds_test.go b/internal/catalogue/holds_test.go deleted file mode 100644 index 2a39fb71..00000000 --- a/internal/catalogue/holds_test.go +++ /dev/null @@ -1,100 +0,0 @@ -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/internal/catalogue/hostname_seat_test.go b/internal/catalogue/hostname_seat_test.go deleted file mode 100644 index c151efa8..00000000 --- a/internal/catalogue/hostname_seat_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// novox/hq ADR 0223 part 3: a machine's names are one seat's. The `hosts` module holding -// `node-hosts-file` became `hostname` holding `node-hostname`, which writes /etc/hostname beside the -// machine's own lines in /etc/hosts. The seat was renamed (ADR 0122), so what claims the old name — a -// manifest registered before the rename — still holds the one seat. - -func withHostnameAlias(t *testing.T) { - t.Helper() - was := aliases - t.Cleanup(func() { aliases = was }) - UseAliases(map[string]string{"node-hosts-file": "node-hostname"}) -} - -func TestTheHostsFilesFormerNameResolvesToTheHostnameSeat(t *testing.T) { - if _, known := SeatNamed("node-hostname"); !known { - t.Fatal("node-hostname is not in the mesh's set") - } - withHostnameAlias(t) - seat, known := SeatNamed("node-hosts-file") - if !known || seat.Name != "node-hostname" || seat.Scope != ScopeNode { - t.Fatalf("the former name did not resolve: %+v %v", seat, known) - } - var verbs []string - for _, v := range seat.Serves { - verbs = append(verbs, v.Name) - } - if strings.Join(verbs, " ") != "entries add remove" { - t.Errorf("the renamed seat serves %v; its verbs are unchanged", verbs) - } -} - -// One seat under either name: the module registered before the rename and the one after cannot both -// hold it on one machine. -func TestTheOldAndTheNewClaimantAreOneSeatOnAMachine(t *testing.T) { - withHostnameAlias(t) - cat := shelf( - mod("hosts", nil, nil, nil, Claim{Name: "node-hosts-file"}), - mod("hostname", nil, nil, nil, Claim{Name: "node-hostname"}), - ) - _, err := Resolve(cat, []string{"hosts", "hostname"}, workstation(), World{}) - if err == nil || !strings.Contains(err.Error(), "node-hostname") { - t.Errorf("hosts and hostname both held the machine's names on one machine: %v", err) - } - if _, err := Resolve(cat, []string{"hosts"}, workstation(), World{}); err != nil { - t.Errorf("a machine still assigned hosts under the old name does not resolve: %v", err) - } -} - -// The catalogue's module: /etc/hostname is the operator's `hostname` setting. Without it the module is -// left out, naming the key — the mesh never renames a machine on its own — and a mesh-wide setting -// naming ${machine:name} gives every machine its mesh name. -func TestTheMachinesNameIsItsSetting(t *testing.T) { - cat := map[string]Manifest{"hostname": catalogueManifest(t, "hostname")} - got, err := Resolve(cat, []string{"hostname"}, Node{Name: "ace", At: "ace.internal"}, World{}) - if err != nil { - t.Fatal(err) - } - machines := map[string]string{"ace.internal": "10.42.0.2"} - nameOf := func(settings SettingsBy) (string, string) { - t.Helper() - composed, err := got.Compose(Rendering{Names: machines, Machines: machines, Suffix: "internal", - Settings: settings}) - if err != nil { - t.Fatal(err) - } - if why := composed.LeftOut["hostname"]; why != "" { - return "", why - } - for _, r := range composed.Resources { - if r["path"] == "/etc/hostname" { - return r["content"].(string), "" - } - } - t.Fatal("no /etc/hostname composed") - return "", "" - } - - if _, why := nameOf(nil); !strings.Contains(why, "hostname") { - t.Errorf("with no setting the machine's name was written, or left out for another reason: %q", why) - } - if name, why := nameOf(SettingsBy{"hostname": {{From: "ace", Values: map[string]any{"hostname": "Ace"}}}}); name != "Ace\n" { - t.Errorf("the operator's name for the machine gave %q (%s)", name, why) - } - mesh := Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}} - if name, why := nameOf(SettingsBy{"hostname": {mesh}}); name != "ace\n" { - t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why) - } - node := Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}} - if name, why := nameOf(SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" { - t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why) - } -} diff --git a/internal/catalogue/identity_bound_test.go b/internal/catalogue/identity_bound_test.go deleted file mode 100644 index f7f27c06..00000000 --- a/internal/catalogue/identity_bound_test.go +++ /dev/null @@ -1,190 +0,0 @@ -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/internal/catalogue/identity_test.go b/internal/catalogue/identity_test.go deleted file mode 100644 index 580bc2e6..00000000 --- a/internal/catalogue/identity_test.go +++ /dev/null @@ -1,53 +0,0 @@ -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/internal/catalogue/installation_test.go b/internal/catalogue/installation_test.go deleted file mode 100644 index 563ed727..00000000 --- a/internal/catalogue/installation_test.go +++ /dev/null @@ -1,93 +0,0 @@ -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/internal/catalogue/invokes_test.go b/internal/catalogue/invokes_test.go deleted file mode 100644 index 7612ad24..00000000 --- a/internal/catalogue/invokes_test.go +++ /dev/null @@ -1,31 +0,0 @@ -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/internal/catalogue/issued_credential_test.go b/internal/catalogue/issued_credential_test.go deleted file mode 100644 index 88b235d0..00000000 --- a/internal/catalogue/issued_credential_test.go +++ /dev/null @@ -1,42 +0,0 @@ -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/internal/catalogue/issued_outside_test.go b/internal/catalogue/issued_outside_test.go deleted file mode 100644 index 54e5d293..00000000 --- a/internal/catalogue/issued_outside_test.go +++ /dev/null @@ -1,46 +0,0 @@ -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/internal/catalogue/jail_problems_test.go b/internal/catalogue/jail_problems_test.go deleted file mode 100644 index 800d36a1..00000000 --- a/internal/catalogue/jail_problems_test.go +++ /dev/null @@ -1,49 +0,0 @@ -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/internal/catalogue/jails_into_test.go b/internal/catalogue/jails_into_test.go deleted file mode 100644 index 95864581..00000000 --- a/internal/catalogue/jails_into_test.go +++ /dev/null @@ -1,72 +0,0 @@ -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/internal/catalogue/kept_test.go b/internal/catalogue/kept_test.go deleted file mode 100644 index f60f8c34..00000000 --- a/internal/catalogue/kept_test.go +++ /dev/null @@ -1,68 +0,0 @@ -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/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go deleted file mode 100644 index 896a87f3..00000000 --- a/internal/catalogue/kinded_test.go +++ /dev/null @@ -1,168 +0,0 @@ -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/internal/catalogue/licence_test.go b/internal/catalogue/licence_test.go deleted file mode 100644 index 0860b62e..00000000 --- a/internal/catalogue/licence_test.go +++ /dev/null @@ -1,269 +0,0 @@ -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/internal/catalogue/localnetwork_test.go b/internal/catalogue/localnetwork_test.go deleted file mode 100644 index 4a0cd6f9..00000000 --- a/internal/catalogue/localnetwork_test.go +++ /dev/null @@ -1,195 +0,0 @@ -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/internal/catalogue/login_shell_execute_test.go b/internal/catalogue/login_shell_execute_test.go deleted file mode 100644 index 51b16e4c..00000000 --- a/internal/catalogue/login_shell_execute_test.go +++ /dev/null @@ -1,41 +0,0 @@ -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/internal/catalogue/logs_test.go b/internal/catalogue/logs_test.go deleted file mode 100644 index 76e7ee0d..00000000 --- a/internal/catalogue/logs_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "strings" - "testing" -) - -// A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap -// in MiB; the default cap where none is said. -func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` + - `"logs":["changes",{"name":"moves","max-mib":2048}]}`)) - if err != nil { - t.Fatal(err) - } - if len(m.Logs) != 2 || m.Logs[0].Name != "changes" || m.Logs[1].Name != "moves" { - t.Fatalf("logs not read: %+v", m.Logs) - } - if m.Logs[0].Cap() != LogDefaultMiB || m.Logs[0].Cap() != 1024 || m.Logs[1].Cap() != 2048 { - t.Fatalf("caps read as %d and %d", m.Logs[0].Cap(), m.Logs[1].Cap()) - } - out, _ := json.Marshal(m.Logs) - if string(out) != `["changes",{"name":"moves","max-mib":2048}]` { - t.Fatalf("written back as %s", out) - } - for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} { - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { - t.Errorf("%s is inside the caps and was refused: %v", edge, err) - } - } -} - -// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a -// field the mesh does not know, or kept by a module whose own name is not one plain token. -func TestALogIsNamedLocallyAndCapped(t *testing.T) { - for _, c := range []struct{ manifest, says string }{ - {`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, - {`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`}, - {`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`}, - {`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, - {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`}, - {`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`}, - } { - _, err := ParseManifest([]byte(c.manifest)) - if err == nil { - t.Errorf("%s was accepted", c.manifest) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) - } - } -} - -// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps. -func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) { - var problems []string - for _, m := range theCatalogue(t) { - problems = append(problems, LogProblems(m)...) - } - if len(problems) > 0 { - t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n ")) - } -} diff --git a/internal/catalogue/machine_into_files_test.go b/internal/catalogue/machine_into_files_test.go deleted file mode 100644 index 74bc60b5..00000000 --- a/internal/catalogue/machine_into_files_test.go +++ /dev/null @@ -1,128 +0,0 @@ -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/internal/catalogue/machineside_test.go b/internal/catalogue/machineside_test.go deleted file mode 100644 index 553e1a5a..00000000 --- a/internal/catalogue/machineside_test.go +++ /dev/null @@ -1,60 +0,0 @@ -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/internal/catalogue/mounts_test.go b/internal/catalogue/mounts_test.go deleted file mode 100644 index f929a5d3..00000000 --- a/internal/catalogue/mounts_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package catalogue - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// A bind mount the module never declared is refused where it is written (novox/hq 04-ISSUES/026, -// ADR 0091). The container runtime creates a missing bind source itself, as root, with a mode it -// picks, so the module's own owner and mode never reach the directory holding its data, and the -// rule that keeps data when a module goes away (ADR 0030) does not cover it. - -const aContainerMounting = `{"id":"server","type":"container","name":"store","image":"x@sha256:` + - `0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}` - -func manifestMounting(from string, beside string) []byte { - res := aContainerMounting - if beside != "" { - res = beside + "," + res - } - return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`) -} - -func TestAMountNothingDeclaresIsRefused(t *testing.T) { - _, err := ParseManifest(manifestMounting("/services/store/data", "")) - if err == nil { - t.Fatal("a container mounting a path no resource declares was accepted; the runtime " + - "would create it as root and the module's owner and mode would never apply") - } - if !strings.Contains(err.Error(), "/services/store/data") { - t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err) - } -} - -// Declaring it is enough — including declaring the directory above it. -func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { - _, err := ParseManifest(manifestMounting("/services/store/data", - `{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`)) - if err != nil { - t.Fatalf("a module that said where its data lives was refused anyway: %v", err) - } -} - -// The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the -// module would then own. -func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + - `"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`)) - if err != nil { - t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err) - } -} - -// The machine's facility — the container runtime's socket — is granted by the capability that names -// it, and declaring it as the module's own directory would be a lie the host would act on. This is -// the case the first version of this check refused, and was withdrawn for: the builder. -func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) { - granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` + - strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}` - if _, err := ParseManifest([]byte(granted)); err != nil { - t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err) - } - if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil { - t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted") - } -} - -// **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue -// is already breaking. -func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { - files, _ := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) - if len(files) == 0 { - t.Fatal("no manifests in the catalogue, so this proved nothing") - } - root := beside.Dir(t, "mesh-catalog") // once: it copies the captured tree - for _, file := range files { - if _, err := ReadTreeManifest(root, filepath.Dir(file)); err != nil { - t.Errorf("%s: %v", filepath.Base(filepath.Dir(file)), err) - } - } -} - -// Where a bound fact lands is the mesh's file too, and a container may mount it directly. -func TestAMountOfABoundFactIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` + - `"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` + - strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`)) - if err != nil { - t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err) - } -} - -// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR -// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it. -func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + - `"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`)) - if err != nil { - t.Fatalf("a filter module mounting its own filter file was refused: %v", err) - } -} diff --git a/internal/catalogue/names_test.go b/internal/catalogue/names_test.go deleted file mode 100644 index 846d9e99..00000000 --- a/internal/catalogue/names_test.go +++ /dev/null @@ -1,100 +0,0 @@ -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/internal/catalogue/naming_test.go b/internal/catalogue/naming_test.go deleted file mode 100644 index c168ea46..00000000 --- a/internal/catalogue/naming_test.go +++ /dev/null @@ -1,75 +0,0 @@ -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/internal/catalogue/needs_test.go b/internal/catalogue/needs_test.go deleted file mode 100644 index 8a176a19..00000000 --- a/internal/catalogue/needs_test.go +++ /dev/null @@ -1,124 +0,0 @@ -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/internal/catalogue/no_subjects_test.go b/internal/catalogue/no_subjects_test.go deleted file mode 100644 index 23c46c34..00000000 --- a/internal/catalogue/no_subjects_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// **A manifest holds no subject** (novox/hq design 29 §1). -// -// A module names its events, tools and seats locally, and the mesh derives where they land. The -// property that buys: reorganise the subject space and every manifest in the catalogue is still -// correct. It holds today by construction — nothing reads a subject from a manifest — and a rule -// held by construction is one a later field breaks quietly, with the symptom appearing as a -// permission that does not match a subject rather than as a manifest that was wrong. -func TestNoManifestContainsASubject(t *testing.T) { - root := beside.Catalogue(t) - entries, err := os.ReadDir(root) - if err != nil { - t.Fatal(err) - } - - // Anything in the mesh's own subject space, and anything shaped like a wire address. - subject := regexp.MustCompile(`^(mesh|\$JS)\.[a-zA-Z0-9_*>.-]+$`) - - var found []string - var walk func(module string, path string, v any) - walk = func(module, path string, v any) { - switch t := v.(type) { - case string: - if subject.MatchString(t) { - found = append(found, module+" "+path+" = "+t) - } - case map[string]any: - for k, inner := range t { - walk(module, path+"."+k, inner) - } - case []any: - for _, inner := range t { - walk(module, path+"[]", inner) - } - } - } - - checked := 0 - for _, e := range entries { - if !e.IsDir() { - continue - } - raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) - if err != nil { - continue - } - var m any - if err := json.Unmarshal(raw, &m); err != nil { - t.Errorf("%s: %v", e.Name(), err) - continue - } - checked++ - walk(e.Name(), "", m) - } - if checked == 0 { - t.Fatal("no manifests read, so this proved nothing") - } - if len(found) > 0 { - t.Errorf("a manifest names a subject, so reorganising the subject space would mean "+ - "editing the catalogue:\n %s", strings.Join(found, "\n ")) - } - t.Logf("%d manifests hold no subject", checked) -} - -// **Every module's event names are what design 29 says, across the whole catalogue.** -// -// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds -// no subject, and every manifest in the catalogue still held the old bus's routing key, which -// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services -// started and none of them reacted. This is the check that was missing. -func TestEveryManifestsEventNamesAreLocal(t *testing.T) { - manifests := theCatalogue(t) - - var problems []string - for _, m := range manifests { - problems = append(problems, EventProblems(m)...) - } - if len(problems) > 0 { - t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s", - len(problems), strings.Join(problems, "\n ")) - } -} - -// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration -// parses one. -func theCatalogue(t *testing.T) []Manifest { - t.Helper() - root := beside.Catalogue(t) - entries, err := os.ReadDir(root) - if err != nil { - t.Fatal(err) - } - var out []Manifest - for _, e := range entries { - if !e.IsDir() { - continue - } - raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) - if err != nil { - continue - } - var m Manifest - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("%s: %v", e.Name(), err) - } - out = append(out, m) - } - if len(out) == 0 { - t.Fatalf("no manifests under %s, so this proved nothing", root) - } - return out -} diff --git a/internal/catalogue/node_resolver_test.go b/internal/catalogue/node_resolver_test.go deleted file mode 100644 index b5cec6d8..00000000 --- a/internal/catalogue/node_resolver_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires -// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it. - -// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder -// exists only once the module serving them does, so nothing has to be optional while it catches up. -func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) { - s, ok := SeatNamed(ResolverSeat) - if !ok { - t.Fatalf("%s is not in the mesh's set", ResolverSeat) - } - if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated { - t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s) - } - var got []string - for _, v := range s.Serves { - got = append(got, v.Name) - if v.Optional { - t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name) - } - if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 { - t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name) - } - } - if strings.Join(got, " ") != "routes route unroute" { - t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got) - } - // The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one. - for _, v := range s.Serves { - if strings.Contains(strings.ToLower(v.Description), "forti") { - t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description) - } - } -} - -// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the -// machine's own address. What is under test is the controller's rule, not the catalogue's module. -func localResolver() Manifest { - return Manifest{Module: "local-resolver", Version: "1", - Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}}, - Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, - Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}} -} - -func splitDNSLaptop() Node { - return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{ - "package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}} -} - -// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming -// the machine's own address; the uplink's holder composes everything else it declares, and not that file. -func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) { - shelf := resolverShelf(t) - shelf["local-resolver"] = localResolver() - got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) - if err != nil { - t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err) - } - out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", - Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, - Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, - Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, - }) - if err != nil { - t.Fatal(err) - } - var files []string - for _, r := range out { - if r["path"] == ResolverFile { - files = append(files, r["id"].(string)) - } - } - if strings.Join(files, " ") != "local-resolver.fact-resolvers" { - t.Fatalf("the resolver file is composed as %v; once, the resolver's", files) - } - content := byID(out)["local-resolver.fact-resolvers"]["content"].(string) - if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") { - t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content) - } - // The uplink's holder is still composed: only the one file moved. - uplinkComposed := false - for _, r := range out { - if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") { - uplinkComposed = true - } - } - if !uplinkComposed { - t.Errorf("the uplink's holder composed nothing once the resolver was held") - } -} - -// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's -// resolvers (ADR 0223) — every machine but the one that requires split-dns. -func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) { - got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", - Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, - Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, - Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, - }) - if err != nil { - t.Fatal(err) - } - if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile { - t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r) - } -} - -// Only the uplink's holder steps back. A third module rendering or declaring the file beside the -// resolver's is two owners of one path, refused as before; and a module rendering it without holding -// the seat is not the resolver, so the uplink's holder does not step back for it. -func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) { - uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, - Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} - if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 { - t.Errorf("the uplink's holder and the resolver's were refused together: %v", p) - } - other := Manifest{Module: "other", Version: "1", - Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}} - if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 { - t.Error("a third module wrote the resolver file beside the resolver's") - } - if p := checkResources([]Manifest{uplink, other}); len(p) == 0 { - t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder") - } - declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{ - {"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}} - if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 { - t.Error("a module declaring the resolver file was let beside the resolver's") - } - // What steps back is the one file: the uplink's other facts stay. - uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"} - if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" { - t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts) - } - if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 { - t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts) - } -} - -// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own -// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides -// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls -// the resolver in. Skipped while the catalogue has no holder. -func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) { - held := 0 - for _, m := range theCatalogue(t) { - if !holdsSeat(m, ResolverSeat) { - continue - } - held++ - f, ok := m.Facts["resolvers"] - if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") { - t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f) - } - provides := false - for _, o := range m.Provides { - if o.Name == "split-dns" && o.Reach == ReachMachine { - provides = true - } - } - if !provides { - t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat) - } - } - if held == 0 { - t.Skip("no module of the catalogue holds node-resolver yet") - } -} - -// The catalogue's systemd-resolved, composed on a machine beside its uplink: the resolver file names the -// machine's own private address alone, its kept copy is the same file, and resolved is given every mesh -// resolver as its default route and the private address to listen on. Skipped without the catalogue. -func TestTheCataloguesResolverComposesOnAMachine(t *testing.T) { - shelf := resolverShelf(t) - shelf["systemd-resolved"] = catalogueManifest(t, "systemd-resolved") - got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "systemd-resolved"}, splitDNSLaptop(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", - Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}}, - Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, - Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, - }) - if err != nil { - t.Fatal(err) - } - ids := byID(out) - resolv, _ := ids["systemd-resolved.fact-resolvers"]["content"].(string) - kept, _ := ids["systemd-resolved.fact-kept"]["content"].(string) - if !strings.Contains(resolv, "\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n") || resolv != kept { - t.Errorf("the resolver file does not name this machine's own resolver alone, or its copy differs:\n%s", resolv) - } - if ids["systemd-networkd.fact-resolvers"] != nil { - t.Error("the uplink's holder still writes the resolver file beside the resolver's") - } - conf, _ := ids["systemd-resolved.fact-resolved"]["content"].(string) - for _, want := range []string{"\nDNS=10.42.0.1 10.42.0.3 \n", "\nDNSStubListenerExtra=10.42.0.2\n", "\nFallbackDNS=\n"} { - if !strings.Contains(conf, want) { - t.Errorf("resolved's drop-in lacks %q:\n%s", want, conf) - } - } - if s, _ := ids["systemd-resolved.fact-suffix"]["content"].(string); s != "internal\n" { - t.Errorf("the mesh's domain is rendered as %q", s) - } -} diff --git a/internal/catalogue/operators_machine_test.go b/internal/catalogue/operators_machine_test.go deleted file mode 100644 index 9dfa7b37..00000000 --- a/internal/catalogue/operators_machine_test.go +++ /dev/null @@ -1,191 +0,0 @@ -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/internal/catalogue/optional_requirements_test.go b/internal/catalogue/optional_requirements_test.go deleted file mode 100644 index 0b6f8083..00000000 --- a/internal/catalogue/optional_requirements_test.go +++ /dev/null @@ -1,301 +0,0 @@ -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) - } - } -}