diff --git a/go/catalogue/a_service_process_test.go b/go/catalogue/a_service_process_test.go index 023d03d..7762e24 100644 --- a/go/catalogue/a_service_process_test.go +++ b/go/catalogue/a_service_process_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "testing" ) @@ -42,17 +41,6 @@ func aServiceModule(t *testing.T) Manifest { 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 { @@ -87,60 +75,3 @@ func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.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) - } -} diff --git a/go/catalogue/accesses_test.go b/go/catalogue/accesses_test.go deleted file mode 100644 index 0ebd1bf..0000000 --- a/go/catalogue/accesses_test.go +++ /dev/null @@ -1,78 +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) - } -} diff --git a/go/catalogue/adoption.go b/go/catalogue/adoption.go index 4420af4..6f05df3 100644 --- a/go/catalogue/adoption.go +++ b/go/catalogue/adoption.go @@ -2,8 +2,6 @@ package catalogue import ( "fmt" - "sort" - "strconv" "strings" ) @@ -32,87 +30,9 @@ const ( GuardUnitPath = "/etc/systemd/system/" + GuardUnit ) -// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer -// raises the same three on an adopted genesis, so the first push finds them already there. -func GuardID() string { return AdoptionPrefix + "guard" } -func GuardUnitID() string { return AdoptionPrefix + "guard-unit" } -func GuardRunningID() string { return AdoptionPrefix + "guard-running" } - -// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has -// no filter module and may have no nft at all, and a table nothing can load guards nothing. -func GuardPackageID() string { return AdoptionPrefix + "guard-package" } - // GuardPackage is the package that carries nft. const GuardPackage = "nftables" -// OpeningID is an opening's resource identity: its protocol, port and path say what it is. -func OpeningID(protocol string, port int, path string) string { - return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path) -} - -// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the -// filter it would load were the node converged, each from where that filter would admit it. -// -// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure — -// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine -// only opens nothing. `published` maps a machine port a container publishes to the container's -// port: a published port is forwarded, not received, so its opening names the forwarded path and -// the port the packet is forwarded to. -func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any { - type key struct { - protocol string - port int - } - from := map[key]string{} - var order []key - widen := func(k key, f string) { - was, seen := from[k] - if !seen { - order = append(order, k) - } - if !seen || was != OpeningFromEverywhere { - from[k] = f - } - } - for _, rule := range rules { - switch rule.From { - case FromEverywhere: - widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere) - case FromMesh: - widen(key{rule.Protocol, rule.Port}, OpeningFromMesh) - case FromLocalNetwork: - // The private network's part only (novox/hq ADR 0340). An adopted node's found firewall - // stays, and it is what admits the machine's local network; an opening has no word for a - // local network, and widening it to everywhere would open the port to the internet. - widen(key{rule.Protocol, rule.Port}, OpeningFromMesh) - } - } - for _, port := range foundation { - widen(key{"tcp", port}, OpeningFromEverywhere) - } - sort.Slice(order, func(a, b int) bool { - if order[a].port != order[b].port { - return order[a].port < order[b].port - } - return order[a].protocol < order[b].protocol - }) - out := make([]map[string]any, 0, len(order)) - for _, k := range order { - opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol, - "from": from[k]} - if to, forwarded := published[k.protocol][k.port]; forwarded { - opening["id"] = OpeningID(k.protocol, k.port, PathForwarded) - opening["path"] = PathForwarded - opening["to"] = to - } else { - opening["id"] = OpeningID(k.protocol, k.port, PathIncoming) - opening["path"] = PathIncoming - } - out = append(out, opening) - } - return out -} - // Published is every port the given containers publish on the machine, by protocol and machine // port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off // the machine reaches it, forwarded or not. @@ -146,90 +66,3 @@ func Published(resources []map[string]any) map[string]map[int]int { } return out } - -// AsGuard renders the mesh's refusal-only table for the given machine ports. -// -// It passes everything by default and holds nothing but a refusal, so it cannot close anything -// the machine serves; and it is the mesh's own table, so the found firewall reloading does not -// touch it. It refuses only packets addressed to this machine, and the ports except from the -// machine itself — its loopback and the container runtime's own networks — and from the private -// network, known by the interface a packet arrives on and never by its source address. At -// prerouting, ahead of the runtime's destination translation, so it matches the port the packet -// was sent to; in the inet family, so both address families. -// -// **The machine's own interfaces are named, where the derived filter names address ranges.** The -// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo, -// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is -// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name) -// would have its containers' traffic to a guarded port refused, which reads as the port being -// down. Names rather than addresses is deliberate: a source address can be claimed by whoever -// sends the packet, and this table exists to refuse what the found firewall never sees. Widening -// it means adding names here and in the installer's copy together, which the golden test holds to -// one text. -// -// The same text the installer raises on an adopted genesis; a test holds both to it. -func AsGuard(ports []int) string { - sorted := append([]int{}, ports...) - sort.Ints(sorted) - listed := make([]string, len(sorted)) - for i, p := range sorted { - listed[i] = strconv.Itoa(p) - } - var b strings.Builder - b.WriteString("table inet mesh_guard {}\n") - b.WriteString("delete table inet mesh_guard\n") - b.WriteString("table inet mesh_guard {\n") - b.WriteString("\tchain prerouting {\n") - b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n") - // Only packets addressed to this machine: traffic it routes for others — a predecessor's hub, - // say — is never the guard's business (novox/hq ADR 0103). - fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+ - "iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n", - strings.Join(listed, ", ")) - b.WriteString("\t}\n") - b.WriteString("}\n") - return b.String() -} - -// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never -// a flush, which would take the container runtime's rules and the found firewall with it. -func GuardUnitText() string { - return "[Unit]\n" + - "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" + - // Early, before the network is up, and without the default dependencies that would - // order it after the network; stopped at shutdown like any unit. - "DefaultDependencies=no\n" + - "Wants=network-pre.target\n" + - "Before=network-pre.target shutdown.target\n" + - "Conflicts=shutdown.target\n" + - "\n" + - "[Service]\n" + - "Type=oneshot\n" + - "RemainAfterExit=yes\n" + - "ExecStart=nft -f " + GuardPath + "\n" + - "ExecReload=nft -f " + GuardPath + "\n" + - "ExecStop=nft delete table inet mesh_guard\n" + - "\n" + - "[Install]\n" + - "WantedBy=multi-user.target\n" -} - -// GuardResources are the guard as four resources of the existing kinds: the tool that loads it, -// the table, the unit, and the unit running — reloaded when the table changes, so the new table -// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and -// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty -// set is not a table nft loads. -func GuardResources(ports []int) []map[string]any { - if len(ports) == 0 { - return nil - } - return []map[string]any{ - {"id": GuardPackageID(), "type": "package", "package": GuardPackage}, - {"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports), - "mode": "0644"}, - {"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(), - "mode": "0644"}, - {"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running", - "boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}}, - } -} diff --git a/go/catalogue/adoption_test.go b/go/catalogue/adoption_test.go index 48b8ad9..8966b0e 100644 --- a/go/catalogue/adoption_test.go +++ b/go/catalogue/adoption_test.go @@ -1,10 +1,7 @@ package catalogue import ( - "encoding/json" "reflect" - "slices" - "strings" "testing" ) @@ -15,14 +12,6 @@ import ( // 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 { @@ -51,24 +40,6 @@ func anAdoptedAnchor() Resolution { }} } -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 { @@ -77,279 +48,6 @@ func byID(resources []map[string]any) map[string]map[string]any { 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 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) - } - } -} - -// 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) { @@ -361,282 +59,3 @@ func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) { t.Fatalf("published is %v, want %v", got, want) } } - -// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine -// itself listens where its software was told to, so giving it a machine port is refused. -func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) { - onTheMachine := Manifest{Module: "daemon", - Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}} - layers := []Layer{{From: "node anchor", - Placement: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}} - _, err := GivenPorts(onTheMachine, layers) - if err == nil || !strings.Contains(err.Error(), "does not publish") { - t.Fatalf("a port no container publishes was given: %v", err) - } -} - -// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded. -// An opening from everywhere beside a guard dropping it is one statement refusing the other. -func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) { - with := anchorRendering(true) - with.Settings["postgres"] = []Layer{{From: "node anchor", - Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}} - composed, err := anAdoptedAnchor().Compose(with) - if err != nil { - t.Fatal(err) - } - got := byID(composed.Resources) - if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != OpeningFromEverywhere { - t.Fatalf("the store's port is not opened to everyone: %v", o) - } - if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) { - t.Fatalf("a port opened to everyone is still guarded:\n%s", guard) - } -} - -// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon -// holds 22 — and says it listens on the machine side of that mapping, which is what anything -// reaching it dials. -func aForge() Manifest { - return Manifest{Module: "forge", - Provides: []Offer{{Name: "git-over-ssh", Scope: ScopeMesh}}, - Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}}, - Listens: []Listening{{Port: 3000, From: FromMesh}, {Port: 2222, From: FromMesh}}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge", - "ports": []any{"3000", "2222:22"}}}} -} - -// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way -// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module -// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written -// here because everything below — the filter, the openings, the guard, what a consumer is told — -// reads that map, and a given port that the lookup does not find moves the container's mapping -// and nothing else. -// -// It stands in for the plan only where the plan does not allocate: a port the manifest already -// placed, or one a node was given. For a short form with no given port the real plan asks the -// inventory for a machine port and may come back with one from the pool, which needs a store and -// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts -// this helper, not the mesh; keep the assertions to the ports under test. -func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int { - out := map[int]int{} - for _, l := range m.Listens { - if at, is := given[l.Port]; is { - out[l.Port] = at - continue - } - at, _ := m.MachineSide(l.Port) - out[l.Port] = at - } - return out -} - -// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module -// publishes, so a node may move it — and a module may name a mapping by either end. -func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) { - forge := aForge() - node := func(v any) []Layer { - return []Layer{{From: "node anchor", Placement: map[string]any{PortsSetting: v}}} - } - - // The machine side — the number this module says it listens on, and the one the predecessor - // had somewhere else. Answered under 2222, the end the module itself names, which is what - // every reader of this map asks for. One entry, not two: a second key for the same answer is - // an entry another mapping's reader could find instead. - given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)})) - if err != nil { - t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) - } - if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) { - t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want) - } - - // The container's own port names the same mapping and means the same thing — and is filed - // under the same name, because the module's name for it has not changed. - if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil || - !reflect.DeepEqual(inside, map[int]int{2222: 222}) { - t.Fatalf("the container's end of the mapping was given %v: %v", inside, err) - } - - // A short form is published on the number it names, and is unchanged by any of this. - if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil || - short[3000] != 2999 { - t.Fatalf("the short form was given %v: %v", short, err) - } - - // A port no container publishes is still refused, in the same words. - if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil || - !strings.Contains(err.Error(), "does not publish") { - t.Fatalf("a port the forge does not publish was given: %v", err) - } - - // And the two ends of one mapping given two different numbers is one setting contradicting - // the other: the machine publishes it once. - if _, err := GivenPorts(forge, node(map[string]any{ - "2222": float64(222), "22": float64(300)})); err == nil || - !strings.Contains(err.Error(), "one mapping") { - t.Fatalf("the two ends of one mapping were given different ports: %v", err) - } - // Said at both ends with the same number, it is still said twice, and refused where every - // other repeated machine port is — as the inventory refuses it when the setting is stored, - // which is the layer that sees it first. - if _, err := GivenPorts(forge, node(map[string]any{ - "2222": float64(222), "22": float64(222)})); err == nil || - !strings.Contains(err.Error(), "to both its 22 and its 2222") { - t.Fatalf("one mapping given one machine port at both ends: %v", err) - } - // A number that names two different mappings names neither: which one to move is not said. - twice := aForge() - twice.Resources[0]["ports"] = []any{"22", "2222:22"} - if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil || - !strings.Contains(err.Error(), "twice") { - t.Fatalf("a number naming two of the module's mappings was accepted: %v", err) - } - - // And the same refusal when the number naming two mappings is not the one the setting used - // but the one the answer would be filed under. Here `80` is the module's own name for a - // mapping, and two mappings wear it; filing an answer there is one container's port standing - // where the other's is read, and both containers then publish it. - shared := Manifest{Module: "gallery", - Listens: []Listening{{Port: 80, From: FromMesh}}, - Resources: []map[string]any{ - {"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}}, - {"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}} - if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil || - !strings.Contains(err.Error(), "twice") { - t.Fatalf("two containers were put on one machine port: %v", err) - } - - // A module whose mappings chain — one's machine side is another's container port — keeps them - // apart, because each is filed under the port the module names it by and neither name is - // shared. Given both, each moves on its own and neither overwrites the other. - chained := Manifest{Module: "chain", - Listens: []Listening{{Port: 80, From: FromMesh}, {Port: 9090, From: FromMesh}}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain", - "ports": []any{"8080:80", "9090:8080"}}}} - both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)})) - if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) { - t.Fatalf("chained mappings were given %v: %v", both, err) - } - if moved := givenOuter("8080:80", both); moved != "1234:80" { - t.Fatalf("the first mapping moved to %q, and it was given 1234", moved) - } - if moved := givenOuter("9090:8080", both); moved != "5678:8080" { - t.Fatalf("the second mapping moved to %q, and it was given 5678", moved) - } -} - -// And the number reaches everything derived from it. The fault this is written against moved the -// container's mapping alone: the filter opened the port the software had left, the adopted node's -// opening named it too, the guard refused it, and a consumer was sent to it. -func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) { - forge := aForge() - given, err := GivenPorts(forge, []Layer{{From: "node anchor", - Placement: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}}) - if err != nil { - t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) - } - r := Resolution{Node: "anchor", Modules: []Manifest{forge}} - with := Rendering{ - Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)}, - Given: map[string]map[int]int{"forge": given}, - Mesh: []string{"10.77.0.1"}, - Adopted: true, - OutwardLinks: []string{"eth0"}, - TunnelInterface: "mesh0", - Taken: map[string]bool{"forge": true}, - } - - // What the runtime is handed: the machine's own port on the outside, the container's within. - composed, err := r.Compose(with) - if err != nil { - t.Fatalf("the forge does not compose: %v", err) - } - got := byID(composed.Resources) - if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) { - t.Fatalf("the forge's container publishes %v", ports) - } - - // What the filter would open, were the node converged. - rules, err := r.Rules(with) - if err != nil { - t.Fatal(err) - } - var opened []int - for _, rule := range rules { - opened = append(opened, rule.Port) - } - // Only the moved port is asserted: the forge's other port is a short form the real plan would - // allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and - // not the mesh's. - if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) { - t.Fatalf("the filter opens %v, not where the machine puts the forge", opened) - } - - // And what it is declared instead, adopted: an opening on the machine's port, naming the - // container's port on the forwarded path — a published port is forwarded, never received. - opening := got[OpeningID("tcp", 222, PathForwarded)] - if opening == nil || opening["to"] != 22 || opening["from"] != OpeningFromMesh { - t.Fatalf("no opening for the port this node gave the forge: %v", keys(got)) - } - for id := range got { - if strings.HasPrefix(id, "adoption.opening-tcp-2222-") { - t.Errorf("an opening for the port the forge was moved off: %s", id) - } - } - - // The guard refuses it where the machine put it, and nothing where it used to be. - guard, _ := got[GuardID()]["content"].(string) - if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") { - t.Fatalf("the guard does not follow the given port:\n%s", guard) - } - - // And a consumer is sent to the same number, which is read from what the module serves. - if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 { - t.Fatalf("a consumer is told the forge answers on %v", told) - } -} - -// And the mapping itself moves under either name, because Rendering.Given is a map anybody -// composing a declaration hands in: keyed by the machine side, which is what a module declaring -// 2222 calls its port, only the outside moves and the container's own port stays as written. -func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) { - for _, c := range []struct { - written string - given map[int]int - want string - }{ - {"2222:22", map[int]int{2222: 222}, "222:22"}, - {"2222:22", map[int]int{22: 222}, "222:22"}, - {"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"}, - {"2222:22", map[int]int{3000: 2999}, "2222:22"}, - {"2222:22", nil, "2222:22"}, - } { - if got := givenOuter(c.written, c.given); got != c.want { - t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want) - } - } -} - -// The same on a node that was given nothing, which is where the derivation was never at fault: -// a long-form mapping is published where the manifest put it, and an adopted node opens that port -// on the forwarded path like any other. What broke it was the number reaching only the mapping. -func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) { - forge := aForge() - r := Resolution{Node: "anchor", Modules: []Manifest{forge}} - composed, err := r.Compose(Rendering{ - Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)}, - Mesh: []string{"10.77.0.1"}, - Adopted: true, - OutwardLinks: []string{"eth0"}, - TunnelInterface: "mesh0", - }) - if err != nil { - t.Fatal(err) - } - got := byID(composed.Resources) - opening := got[OpeningID("tcp", 2222, PathForwarded)] - if opening == nil || opening["to"] != 22 { - t.Fatalf("no opening for the forge's published ssh port: %v", keys(got)) - } -} diff --git a/go/catalogue/agent_account_test.go b/go/catalogue/agent_account_test.go deleted file mode 100644 index 204e866..0000000 --- a/go/catalogue/agent_account_test.go +++ /dev/null @@ -1,85 +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 := decodeForTest([]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) - } -} diff --git a/go/catalogue/artifact_store_seat_test.go b/go/catalogue/artifact_store_seat_test.go deleted file mode 100644 index 182dcac..0000000 --- a/go/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/go/catalogue/artifacts.go b/go/catalogue/artifacts.go index 14b4f90..cb4bc06 100644 --- a/go/catalogue/artifacts.go +++ b/go/catalogue/artifacts.go @@ -1,7 +1,6 @@ package catalogue import ( - "fmt" "strings" ) @@ -57,60 +56,3 @@ func Routed(reference, address string) string { } return address + "/" + path } - -// artifactsInto composes the artifact store's address into a resource's `image` and `source`. -// -// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is -// routed through the store as this network reaches it now. A reference recorded with an address -// before references were kept without one is re-routed the same way — but only when the mesh -// built it (`with.Built` names every `/` it has), because a module may run an -// image from a public registry under its own name and that one is exactly where it says. -// -// A kept reference with no store to route it through is refused: sent as it is, the runtime would -// refuse the scheme on the machine, one push away from the reason. -// -// **What this does not reach: the images genesis pinned.** The installer builds the control plane -// and the builder before the mesh exists, pushes them itself and pins their manifests to -// `:/mesh-controller@…` and `:/mesh-builder@…` — single-segment -// repositories with no build record, so `with.Built` does not name them and they are left as -// written until each is rebuilt through the mesh, which records it by digest and path. Until then -// a registry that moves strands exactly those two on a recreate, and the control plane's is the -// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the -// store (novox/hq 04-ISSUES/102, finding F4). -func artifactsInto(resource map[string]any, module string, with Rendering) error { - for _, key := range []string{"image", "source"} { - written, ok := resource[key].(string) - if !ok { - continue - } - if _, kept := InArtifactStore(written); kept { - if with.ArtifactStore == "" { - return fmt.Errorf( - "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ - "artifact store on its network to fetch it from — nothing assigned offers "+ - "%s, or the machine offering it is not on the private network", - module, resource["id"], written, ArtifactStoreProvision) - } - resource[key] = Routed(written, with.ArtifactStore) - continue - } - if with.ArtifactStore == "" { - continue - } - recorded := Recorded(written) - if recorded == written { - continue - } - path, _ := InArtifactStore(recorded) - repository := path - if at := strings.IndexAny(path, "@"); at >= 0 { - repository = path[:at] - } else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 { - repository = path[:blobs] - } - if with.Built[repository] { - resource[key] = Routed(recorded, with.ArtifactStore) - } - } - return nil -} diff --git a/go/catalogue/artifacts_test.go b/go/catalogue/artifacts_test.go index c0ea515..17ca60c 100644 --- a/go/catalogue/artifacts_test.go +++ b/go/catalogue/artifacts_test.go @@ -27,95 +27,3 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { } } } - -// **The address is composed into a declaration, and the record never carries it.** -func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { - m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ - {"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"}, - {"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"}, - {"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest}, - }, Build: &Build{Artifacts: []Artifact{ - {Name: "server", Kind: ArtifactImage, From: "Dockerfile"}, - {Name: "config", Kind: ArtifactArchive, From: "config"}, - }}} - resolved, err := m.Resolve([]Built{ - {Name: "server", Kind: ArtifactImage, Reference: ArtifactStoreScheme + "gitea/server@" + digest}, - {Name: "config", Kind: ArtifactArchive, Reference: ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest}, - }) - if err != nil { - t.Fatal(err) - } - r := Resolution{Node: "anchor", Modules: []Manifest{resolved}} - - out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"}) - if err != nil { - t.Fatal(err) - } - if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest { - t.Errorf("the image the mesh built is fetched as %v", got) - } - if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { - t.Errorf("the archive the mesh built is fetched from %v", got) - } - if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest { - t.Errorf("an image from a public registry was routed through the store: %v", got) - } - // The manifest the mesh holds still says what it is, not where it was fetched from. - if got := resolved.Resources[0]["image"]; got != ArtifactStoreScheme+"gitea/server@"+digest { - t.Errorf("composing wrote the address into the catalogue's copy: %v", got) - } - - // And the store moves: the same record, another address, without a rebuild. - out, err = r.Declaration(Rendering{ArtifactStore: "laptop.internal:5000"}) - if err != nil { - t.Fatal(err) - } - if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest { - t.Errorf("after the store moved, the image is still fetched as %v", got) - } -} - -func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) { - m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ - {"id": "server", "type": "container", "name": "mesh-gitea", - "image": ArtifactStoreScheme + "gitea/server@" + digest}, - }} - _, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(Rendering{}) - if err == nil || !strings.Contains(err.Error(), "no artifact store") { - t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err) - } -} - -// **A reference recorded with an address before this follows the store too** — when the mesh -// built it. A module running an image straight from a public registry under its own name is left -// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called. -func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) { - m := Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{ - {"id": "server", "type": "container", "name": "mesh-keycloak", - "image": "anchor.internal:5100/keycloak/server@" + digest}, - {"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream", - "image": "quay.io/keycloak/keycloak@" + digest}, - }} - r := Resolution{Node: "anchor", Modules: []Manifest{m}} - out, err := r.Declaration(Rendering{ - ArtifactStore: "anchor.internal:5101", - Built: map[string]bool{"keycloak/server": true}, - }) - if err != nil { - t.Fatal(err) - } - if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest { - t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got) - } - if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest { - t.Errorf("a public image was re-routed through the store: %v", got) - } - // Nothing known to be built: nothing re-routed, nothing refused. - out, err = r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"}) - if err != nil { - t.Fatal(err) - } - if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest { - t.Errorf("with no build record, a reference was rewritten: %v", got) - } -} diff --git a/go/catalogue/backup_placement.go b/go/catalogue/backup_placement.go index 2e77dab..e1fa098 100644 --- a/go/catalogue/backup_placement.go +++ b/go/catalogue/backup_placement.go @@ -1,47 +1,4 @@ package catalogue -import ( - "fmt" - "regexp" -) - // BackupNamespace is the placeholder kind of the machine's backup lines and data. const BackupNamespace = "backup" - -// ofBackup is where a holder places them: ${backup:}. Loose inside the braces, so a misspelt key is found and -// refused rather than written out as text. -var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`) - -// backupKinds is the contribution kind each key of the backup placement renders. -var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData} - -// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are. -type backupPlacer struct { - modules []Manifest - with Rendering - facts map[string]string - caps map[string]bool - unplaced *[]string -} - -// placed is the text of one key for a holder, or what is wrong with naming it. -func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) { - kind, known := backupKinds[key] - switch { - case item: - return "", "an item is kept per item; the machine's backup lines are placed only in a file" - case !known: - return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key) - case !holder.ClaimsSeat(BackupSeat): - return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+ - "(novox/hq ADR 0212)", holder.Module, BackupSeat) - } - text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps) - if b.unplaced != nil { - *b.unplaced = append(*b.unplaced, left...) - } - if err != nil { - return "", err.Error() - } - return text, "" -} diff --git a/go/catalogue/backup_placement_test.go b/go/catalogue/backup_placement_test.go index ba566ca..88eca30 100644 --- a/go/catalogue/backup_placement_test.go +++ b/go/catalogue/backup_placement_test.go @@ -1,126 +1,10 @@ package catalogue -import ( - "reflect" - "strings" - "testing" -) - // A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as // `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded // one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text. -const ( - oldBackupText = "# lines\n${contribution:node-backup:backup}" - oldDataText = "# data\n${contribution:node-backup:data}" - placedBackupTxt = "# lines\n${backup:lines}" - placedDataTxt = "# data\n${backup:data}" - backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}], - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"}, - {"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}` -) - func dataModule(name string) string { return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], "data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}` } - -// backupsOn is a machine of a holder whose files say these texts and the modules that declare data. -func backupsOn(t *testing.T, lines, data string, modules ...string) Resolution { - t.Helper() - all := []Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))} - names := []string{"restic"} - for _, name := range modules { - all = append(all, mustParse(t, dataModule(name))) - names = append(names, name) - } - r, err := Resolve(shelf(all...), names, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account = "op" - return r -} - -var backupModules = []string{"alpha", "beta", "gamma"} - -// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort. -func gammaLeftOut(with Rendering) Rendering { - with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"} - return with -} - -func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) { - // The old form is an unmoved holder: its placeholders are the controller's alone. - oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1) - all := []Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))} - for _, name := range backupModules { - all = append(all, mustParse(t, dataModule(name))) - } - old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), World{}) - if err != nil { - t.Fatal(err) - } - old.Account = "op" - wantLines, wantData := fileOf(t, old, gammaLeftOut(Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(Rendering{}), "restic.data-declared") - if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") { - t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData) - } - moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...) - // Not handed: the controller fills the new placeholders with the old text. - if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.declared"); got != wantLines { - t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines) - } - if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.data-declared"); got != wantData { - t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData) - } - // Handed: the plan carries the same text as placement, and the files are the client's. - c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000"))) - p := c.Plans["restic"].Placement - if got := "# lines\n" + p["backup:lines"]; got != wantLines { - t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines) - } - if got := "# data\n" + p["backup:data"]; got != wantData { - t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData) - } - if len(p) != 2 { - t.Errorf("the plan's placement is %v", p) - } -} - -func TestAModuleAddedOrTakenAwayChangesTheBackupPlan(t *testing.T) { - plan := func(modules ...string) map[string]string { - c, _ := composeWith(t, backupsOn(t, placedBackupTxt, placedDataTxt, modules...), handingTo("store:5000")) - return c.Plans["restic"].Placement - } - two, three := plan("alpha", "beta"), plan("alpha", "beta", "gamma") - if reflect.DeepEqual(two, three) || strings.Contains(two["backup:lines"], "gamma") || !strings.Contains(three["backup:lines"], "# gamma\n") { - t.Errorf("two modules give %v, three give %v", two, three) - } -} - -func TestAnUnknownBackupKeyIsRefused(t *testing.T) { - r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...) - if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") { - t.Errorf("handed, an unknown key is %v", err) - } - if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") { - t.Errorf("not handed, an unknown key is %v", err) - } -} - -func fileOf(t *testing.T, r Resolution, with Rendering, id string) string { - t.Helper() - out, err := r.Declaration(with) - if err != nil { - t.Fatal(err) - } - for _, res := range out { - if res["id"] == id { - return res["content"].(string) - } - } - t.Fatalf("%s was not composed", id) - return "" -} diff --git a/go/catalogue/backup_test.go b/go/catalogue/backup_test.go deleted file mode 100644 index f45093f..0000000 --- a/go/catalogue/backup_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// 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 := decodeForTest([]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 := decodeForTest([]byte(`{"module":"mail","version":"1", - "resources":[{"id":"spool","type":"directory","mode":"0700"}], - "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) - if err != nil { - t.Fatal(err) - } - placed, _, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} { - if !strings.Contains(placed, want) { - t.Errorf("placed contributions lack %q:\n%s", want, placed) - } - } - if strings.Contains(placed, "${dir:") { - t.Errorf("a directory reached the holder unfilled:\n%s", placed) - } -} diff --git a/go/catalogue/bound.go b/go/catalogue/bound.go index 2f2a346..8def4e2 100644 --- a/go/catalogue/bound.go +++ b/go/catalogue/bound.go @@ -2,7 +2,6 @@ package catalogue import ( "fmt" - "slices" "strings" ) @@ -25,99 +24,6 @@ func (k KeptBinding) String() string { k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module) } -// keepBound holds every need for a provision that keeps its consumers' data at the provider its -// consumer was bound to, where the resolution chose another. -// -// A need with no record is a binding being made, and is left as resolved: it is recorded when it is -// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too. -// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer, -// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused, -// never answered by the provider chosen**: answering it there is exactly the silent move this exists -// to stop, and the consumer's data is still wherever it was. -func keepBound(needs []Needed, catalogue map[string]Manifest, node Node, world World, - keeps map[string]bool, here func(string) bool) ([]Needed, []KeptBinding, []string) { - var kept []KeptBinding - var problems []string - refused := map[[2]string]bool{} - out := make([]Needed, 0, len(needs)) - for _, n := range needs { - if n.ByRecord || !keeps[n.Name] { - out = append(out, n) - continue - } - n.KeepsData = true - bound, recorded := world.Bound[n.For][n.Name] - chose := Chosen{Node: n.From, Module: n.Module} - if !recorded || sameProvider(bound, chose) { - out = append(out, n) - continue - } - if pin, pinned := world.Pinned[n.Name]; pinned && pin.matches(Provider{Node: chose.Node, Module: chose.Module}) { - out = append(out, n) - continue - } - held, ok, why := boundNeed(n, bound, catalogue, node, world, here) - if !ok { - if key := [2]string{n.For, n.Name}; !refused[key] { - refused[key] = true - problems = append(problems, fmt.Sprintf( - "%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+ - "which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+ - "%s back what it provided", - n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module, - bound.Node)) - } - continue - } - out = append(out, held) - kept = append(kept, KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose}) - } - return out, kept, problems -} - -// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none -// is written without one, but a person's hand might) matches any module on its node. -func sameProvider(bound, chose Chosen) bool { - return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module) -} - -// boundNeed is the need answered by the recorded provider, or why it cannot be. -func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node, world World, - here func(string) bool) (Needed, bool, string) { - held := Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true} - if bound.Node == node.Name { - m, known := catalogue[bound.Module] - if !known || !here(bound.Module) || !providesAt(m, n.Name, ScopeMesh) { - return Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name) - } - at := node.At - if at == "" { - at = "127.0.0.1" - } - held.From, held.At, held.Module = node.Name, at, m.Module - held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name) - held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name) - return held, true, "" - } - matching := bound.among(world.Offered[n.Name]) - if len(matching) != 1 { - return Needed{}, false, fmt.Sprintf("%s no longer provides it", bound) - } - p := matching[0] - if node.At == "" || p.At == "" { - return Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node) - } - identity := DefaultIdentityBound - if pm, known := catalogue[p.Module]; known { - held.SharedOwn, _ = pm.SharedCredentialOf(n.Name) - held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name) - identity = pm.IdentityBoundOf(n.Name) - } - held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity - held.LeftOut = p.LeftOut - return held, true, "" -} - // Unbound is one consumer that still asks for a provision and whose own resolution binds it to // another provider than the one a pair credential on record was made with (novox/hq issue 274). // @@ -153,19 +59,3 @@ func (u Unbound) String() string { "login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+ "record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider) } - -// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the -// module that requires it and the credential's local name — answered by a machine rather than by a -// record. None means this machine states no such binding. -func (r Resolution) BindsFrom(provision, module, local string) []string { - var from []string - for _, n := range r.Needs { - if n.ByRecord || n.Name != provision || n.For != module || n.Local != local { - continue - } - if !slices.Contains(from, n.From) { - from = append(from, n.From) - } - } - return from -} diff --git a/go/catalogue/bound_into_files.go b/go/catalogue/bound_into_files.go index 70ff0b3..0d4e78a 100644 --- a/go/catalogue/bound_into_files.go +++ b/go/catalogue/bound_into_files.go @@ -1,171 +1,5 @@ package catalogue -import ( - "fmt" - "regexp" - "sort" - "strings" -) - -// bound is where a module says a value from one of its bindings belongs: -// ${bound::}, and for an optional requirement the value it takes while absent, -// ${bound::|default:} (novox/hq ADR 0330 §4). A default is never empty: an -// absent requirement is never rendered as an empty string. -var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`) - -// knownFor is everything a module may name from one of its bindings. -// -// Three facts the mesh states about any provision, plus whatever the provider said it serves. A -// module may not reach a binding it does not have — the same boundary as a secret, for the same -// reason. -func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) { - out := map[string]map[string]string{} - for _, want := range m.Wants() { - for i := range needs { - n := needs[i] - if n.Name != want || n.For != m.Module { - continue - } - as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)) - values := map[string]string{ - "at": n.At, - "from": n.From, - "as": as, - } - // What the provider derives for this consumer rather than for all of them - // (novox/hq ADR 0202). Filled here, the one place a provision and the module - // requiring it are both in hand. - served, err := ServedTo(n.Serves, as) - if err != nil { - return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err) - } - for key, value := range served { - // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, - // which is what a float would write and what a connection string would refuse. - values[key] = plainly(value) - } - out[want] = values - } - } - return out, nil -} - -// withOwnNames adds a module's own composed names to what it may name from one binding: -// `${bound::name}` and `:internal-name`, and for several contributions to one requirement -// `:name-` / `:internal-name-`. Set over anything the provider serves under those keys: -// what the module is called is the mesh's statement, not the provider's. -func withOwnNames(values map[string]string, own map[string]any) { - for _, key := range []string{"name", "internal-name"} { - if v, ok := own[key].(string); ok { - values[key] = v - } - } - many, _ := own["names"].(map[string]any) - for local, raw := range many { - names, _ := raw.(map[string]any) - for _, key := range []string{"name", "internal-name"} { - if v, ok := names[key].(string); ok { - values[key+"-"+local] = v - } - } - } -} - -// plainly renders a served value as a program would expect to read it. -func plainly(value any) string { - switch v := value.(type) { - case string: - return v - case float64: - if v == float64(int64(v)) { - return fmt.Sprintf("%d", int64(v)) - } - return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") - case bool: - return fmt.Sprintf("%t", v) - case nil: - return "" - default: - return fmt.Sprint(v) - } -} - -// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows. -// -// A placeholder naming something the module does not require, or a key the provider does not -// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a -// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing -// somewhere that names neither the module nor the mesh. -// -// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR -// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses -// that already; this is the same rule where the file is made, for a manifest registered before it. -func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error { - if fmt.Sprint(resource["type"]) != "file" { - return nil - } - content, ok := resource["content"].(string) - if !ok { - return nil - } - var refused error - filled := bound.ReplaceAllStringFunc(content, func(token string) string { - if refused != nil { - return token - } - m := bound.FindStringSubmatch(token) - provision, key, fallback := m[1], m[2], m[3] - values, has := known[provision] - if !has { - if absent[provision] { - if fallback != "" { - return fallback - } - refused = fmt.Errorf( - "%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+ - "value for then (${bound:%s:%s|default:}), so it would be written empty "+ - "(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key) - return token - } - refused = fmt.Errorf( - "%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s", - module, provision, key, module, provision, orNothing(namesOfBindings(known))) - return token - } - value, said := values[key] - if !said { - refused = fmt.Errorf( - "%s asks its %s binding for %q, and what answers it says %s", - module, provision, key, orNothing(namesOfKeys(values))) - return token - } - return value - }) - if refused != nil { - return refused - } - resource["content"] = filled - return nil -} - -func namesOfBindings(known map[string]map[string]string) []string { - var names []string - for name := range known { - names = append(names, fmt.Sprintf("%q", name)) - } - sort.Strings(names) - return names -} - -func namesOfKeys(values map[string]string) []string { - var names []string - for key := range values { - names = append(names, fmt.Sprintf("%q", key)) - } - sort.Strings(names) - return names -} - func orNothing(names []string) string { if len(names) == 0 { return "nothing" diff --git a/go/catalogue/bound_into_files_test.go b/go/catalogue/bound_into_files_test.go index 7abfa3e..48372fa 100644 --- a/go/catalogue/bound_into_files_test.go +++ b/go/catalogue/bound_into_files_test.go @@ -1,217 +1,10 @@ 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 { @@ -232,34 +25,3 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) { t.Fatal("one module on two machines shares an identity") } } - -// A machine's resolver configuration must name its resolver by address — it cannot resolve the name -// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing -// machine's private address beside its name, and only when the machine has one. -func TestABindingOffersTheProvidersAddress(t *testing.T) { - consumer := func() Resolution { - return Resolution{ - Node: "workstation", - Modules: []Manifest{{ - Module: "resolv-conf", - Requires: []string{"wildcard-resolution"}, - Resources: []map[string]any{{ - "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", - "content": "nameserver ${bound:wildcard-resolution:address}\n", - }}, - }}, - Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}}, - } - } - out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}}) - if err != nil { - t.Fatal(err) - } - if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" { - t.Fatalf("the resolver is not named by its address: %q", got) - } - // A machine with no address yet: refused, never written with a blank where the address belongs. - if _, err := consumer().Declaration(Rendering{}); err == nil { - t.Fatal("a file naming an address the mesh does not have was composed") - } -} diff --git a/go/catalogue/bound_test.go b/go/catalogue/bound_test.go index d152fff..d4f8ffa 100644 --- a/go/catalogue/bound_test.go +++ b/go/catalogue/bound_test.go @@ -14,200 +14,6 @@ import ( // 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 { @@ -238,20 +44,3 @@ func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) { t.Error("a name any provider says keeps data keeps data") } } - -// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it -// grants a credential. -func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) { - shelf := storeMesh() - no := false - s := shelf["store"] - s.Provides = []Offer{{Name: "postgres-database", Scope: ScopeMesh, KeepsConsumerData: &no}} - shelf["store"] = s - got, err := Resolve(shelf, homeAssigned(), home, storeWorld()) - if err != nil { - t.Fatal(err) - } - if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" { - t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From) - } -} diff --git a/go/catalogue/broker_seat_test.go b/go/catalogue/broker_seat_test.go index c04389a..c2b5af4 100644 --- a/go/catalogue/broker_seat_test.go +++ b/go/catalogue/broker_seat_test.go @@ -1,51 +1,9 @@ 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. diff --git a/go/catalogue/brokered_test.go b/go/catalogue/brokered_test.go index 0ff4800..e5022de 100644 --- a/go/catalogue/brokered_test.go +++ b/go/catalogue/brokered_test.go @@ -1,8 +1,6 @@ package catalogue import ( - "encoding/json" - "strings" "testing" ) @@ -22,155 +20,6 @@ func reachable() Node { 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. @@ -184,114 +33,3 @@ func TestTheShortFormStillMeansHere(t *testing.T) { } // 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) - } - } -} diff --git a/go/catalogue/bucket_shares.go b/go/catalogue/bucket_shares.go index 9443f7c..be46e14 100644 --- a/go/catalogue/bucket_shares.go +++ b/go/catalogue/bucket_shares.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "regexp" - "sort" ) // BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may @@ -82,43 +81,3 @@ func readRequireShares(raw json.RawMessage) (map[string][]BucketShare, error) { } return out, nil } - -// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move -// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered. -func sortedShares(shares []BucketShare) []BucketShare { - out := make([]BucketShare, 0, len(shares)) - for _, s := range shares { - methods := append([]string(nil), s.Methods...) - sort.Strings(methods) - out = append(out, BucketShare{To: s.To, Methods: methods}) - } - sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To }) - return out -} - -// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own -// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8). -func withShares(m Manifest, requirement string, values map[string]any) (map[string]any, error) { - shares := m.RequireShares[requirement] - if _, named := values[SharesValue]; named && requirement == BucketRequirement { - return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)", - m.Module, requirement) - } - if len(shares) == 0 { - return values, nil - } - out := make(map[string]any, len(values)+1) - for k, v := range values { - out[k] = v - } - listed := []any{} - for _, s := range sortedShares(shares) { - methods := []any{} - for _, method := range s.Methods { - methods = append(methods, method) - } - listed = append(listed, map[string]any{"to": s.To, "methods": methods}) - } - out[SharesValue] = listed - return out, nil -} diff --git a/go/catalogue/bucket_shares_test.go b/go/catalogue/bucket_shares_test.go index eda2356..3281df5 100644 --- a/go/catalogue/bucket_shares_test.go +++ b/go/catalogue/bucket_shares_test.go @@ -12,40 +12,6 @@ import ( const filesWithShares = `{"module":"files","version":"1","requires":["shell", {"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}` -func minioLike() Manifest { - return Manifest{Module: "minio", Version: "1", Provides: Offers("s3-bucket"), - Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}} -} - -func givenToMinio(t *testing.T, out []map[string]any) []Contribution { - t.Helper() - for _, r := range out { - if r["path"] != "/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("minio was given no file: %v", out) - return nil -} - -func sharesOf(t *testing.T, values map[string]any) string { - t.Helper() - raw, err := json.Marshal(values["shares"]) - if err != nil { - t.Fatal(err) - } - return string(raw) -} - -const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]` - // 1591/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them. func TestSharesParseAndAreStoredBesideRequires(t *testing.T) { m := mustParse(t, filesWithShares) @@ -104,62 +70,3 @@ func TestAMalformedShareIsRefused(t *testing.T) { t.Errorf("the stored form was accepted: %v", err) } } - -// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike. -func TestTheGrantCarriesTheSharesSorted(t *testing.T) { - m := mustParse(t, filesWithShares) - remote := Resolution{Node: "laptop", Modules: []Manifest{m}} - values, asks, err := remote.ContributionsFrom("s3-bucket", "files", SettingsBy{}) - if err != nil || !asks { - t.Fatalf("asks %v, err %v", asks, err) - } - if got := sharesOf(t, values); got != sortedSharesJSON { - t.Fatalf("the grant values carry %s", got) - } - // As the provider's file shows it, through a grant. - provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := provider.Declaration(Rendering{Grants: []Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}}) - if err != nil { - t.Fatal(err) - } - given := givenToMinio(t, out) - if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON { - t.Fatalf("the remote consumer's shares did not reach the file: %+v", given) - } - - // Co-located: the consumer contributes to s3-bucket too, and no grant is passed. - co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}}, - "requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`) - both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - out, err = both.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - given = givenToMinio(t, out) - if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" { - t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given) - } -} - -// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. -func TestAConsumerCannotContributeSharesItself(t *testing.T) { - _, err := decodeForTest([]byte(`{"module":"files","version":"1", - "contributes":{"s3-bucket":{"shares":"x"}}, - "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) - if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { - t.Errorf("both written, accepted: %v", err) - } - // Without the field the grant is still refused, so the provider is never told something the manifest did not say. - m := Manifest{Module: "files", Requires: []string{"s3-bucket"}, - Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}} - r := Resolution{Node: "laptop", Modules: []Manifest{m}} - if _, _, err := r.ContributionsFrom("s3-bucket", "files", SettingsBy{}); err == nil { - t.Error("a contributed `shares` reached the grant") - } -} diff --git a/go/catalogue/build.go b/go/catalogue/build.go index bc59665..975f461 100644 --- a/go/catalogue/build.go +++ b/go/catalogue/build.go @@ -363,6 +363,3 @@ func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string { } return loads } - -// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools). -func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) } diff --git a/go/catalogue/busy.go b/go/catalogue/busy.go index d7b81ab..54255ed 100644 --- a/go/catalogue/busy.go +++ b/go/catalogue/busy.go @@ -67,18 +67,3 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) { } return b, true, problems } - -// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that -// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the -// container is replaced at once, as before. -func busyInto(resource map[string]any, with Rendering) { - if _, has := resource[BusyField]; !has { - return - } - if !with.ReadsBusy { - delete(resource, BusyField) - return - } - b, _, _ := ReadBusy(resource) - resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()} -} diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go deleted file mode 100644 index fbaa553..0000000 --- a/go/catalogue/busy_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "testing" -) - -// 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 TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { - m, err := decodeForTest(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) - } - if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has { - t.Error("composing wrote the timeout into the catalogue's own manifest") - } -} diff --git a/go/catalogue/catalogue_check_test.go b/go/catalogue/catalogue_check_test.go index b2f8e60..ce98085 100644 --- a/go/catalogue/catalogue_check_test.go +++ b/go/catalogue/catalogue_check_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "path/filepath" "testing" "git.novox.be/novox/mesh-sdk/go/internal/beside" @@ -19,28 +18,3 @@ func catalogueRoot(t *testing.T) string { t.Helper() return beside.Dir(t, "mesh-catalog") } - -func TestEveryCatalogueManifestParses(t *testing.T) { - _, found := catalogueManifests(t) - named, routed := 0, 0 - for _, r := range found { - m, err := r.manifest, r.err - if err != nil { - t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), 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") - } -} diff --git a/go/catalogue/catalogue_read_test.go b/go/catalogue/catalogue_read_test.go index 50ca38a..9e0b4e7 100644 --- a/go/catalogue/catalogue_read_test.go +++ b/go/catalogue/catalogue_read_test.go @@ -1,44 +1,8 @@ package catalogue -import ( - "path/filepath" - "sync" - "testing" -) - // readManifest is one manifest of the catalogue as ReadTreeManifest read it. type readManifest struct { path string // the module.json manifest Manifest err error } - -var ( - readOnce sync.Once - readAll []readManifest - readRoot string -) - -// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once -// per test binary and not once per test: the checks over the real catalogue each read and parsed every -// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is -// shared and read-only: a test that changes a manifest copies it first. -func catalogueManifests(t *testing.T) (string, []readManifest) { - t.Helper() - root := catalogueRoot(t) - readOnce.Do(func() { - readRoot = root - found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) - if err != nil { - return - } - for _, p := range found { - m, err := ReadTreeManifest(root, filepath.Dir(p)) - readAll = append(readAll, readManifest{path: p, manifest: m, err: err}) - } - }) - if len(readAll) == 0 { - t.Fatalf("no manifests under %s", root) - } - return readRoot, readAll -} diff --git a/go/catalogue/catrust_manifest_test.go b/go/catalogue/catrust_manifest_test.go deleted file mode 100644 index d55bdb8..0000000 --- a/go/catalogue/catrust_manifest_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package catalogue - -import ( - "path/filepath" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/internal/beside" -) - -// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR -// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless -// the mesh fills in where the authority is — which is the one thing about it the manifest cannot -// state, because the authority's address is a fact about the mesh and not about the module. -// -// So what is checked here is the rendering, not the parsing: the script the machine will run -// names the authority it was bound to, and the unit runs that script both ways. The verification -// itself — a plain client trusting an internal name on a machine holding this, and failing on one -// that does not — is the lab's, and cannot be had here. -func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { - m, err := ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) - if err != nil { - t.Fatalf("the trust module does not parse:\n%v", err) - } - - r := Resolution{ - Node: "workstation", - Modules: []Manifest{m}, - Needs: []Needed{{ - Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", - Serves: map[string]any{ - "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", - }, - }}, - } - out, err := r.Declaration(Rendering{}) - if err != nil { - t.Fatalf("the trust module could not be composed for a machine: %v", err) - } - - script := fileNamed(out, "ca-trust.anchor") - if script == nil { - t.Fatalf("nothing writes the script the unit runs: %v", out) - } - body, _ := script["content"].(string) - if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { - t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) - } - if script["mode"] != "0755" { - t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) - } - - unit := fileNamed(out, "ca-trust.unit") - if unit == nil { - t.Fatalf("no unit: %v", out) - } - text, _ := unit["content"].(string) - // Both halves. A unit that only installs the anchor leaves a machine trusting an authority - // nobody assigned it to any more, which is the half issue 129 asked for by name. - for _, want := range []string{ - "ExecStart=" + script["path"].(string) + " install", - "ExecStop=" + script["path"].(string) + " remove", - "RemainAfterExit=yes", - } { - if !strings.Contains(text, want) { - t.Errorf("the unit does not say %q:\n%s", want, text) - } - } -} diff --git a/go/catalogue/chosen.go b/go/catalogue/chosen.go index 1fb8233..a7d7a15 100644 --- a/go/catalogue/chosen.go +++ b/go/catalogue/chosen.go @@ -1,9 +1,5 @@ package catalogue -import ( - "sort" -) - // Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both, // always (novox/hq #258) — a provision comes from a module, and the same module on two machines is // two answers, so neither half alone says which. Module is empty only on a record made before this @@ -20,76 +16,6 @@ func (c Chosen) String() string { return c.Node + "/" + c.Module } -// matches is whether this provider is the one chosen. -func (c Chosen) matches(p Provider) bool { - return p.Node == c.Node && (c.Module == "" || p.Module == c.Module) -} - -// among is every offered provider the choice names — one, when the choice is whole. -func (c Chosen) among(where []Provider) []Provider { - var out []Provider - for _, p := range where { - if c.matches(p) { - out = append(out, p) - } - } - return out -} - -// nameOf is how a refusal names a provider: the node and the module on it. -func nameOf(p Provider) string { - return Chosen{Node: p.Node, Module: p.Module}.String() -} - -// providerNames is every provider named, sorted, for a refusal to list. -func providerNames(where []Provider) []string { - out := make([]string, 0, len(where)) - for _, p := range where { - out = append(out, nameOf(p)) - } - sort.Strings(out) - return out -} - -// providersHere is which modules in this node's own set offer a provision, sorted. -func providersHere(catalogue map[string]Manifest, here func(string) bool, want string) []string { - var out []string - for name, m := range catalogue { - if !here(name) { - continue - } - for _, o := range m.Offers() { - if o == want { - out = append(out, name) - break - } - } - } - sort.Strings(out) - return out -} - -// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing. -// -// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq -// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest -// alone answers that; the values are settled later, with the node's settings. -func servedByOne(m Manifest, want string) map[string]any { - if _, ok := m.Serves[want]; ok { - return ServedOn(m, want, nil) - } - return nil -} - -// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it -// gives each consumer its own. -func sharedByOne(m Manifest, want string) string { - if own, shared := m.SharedCredentialOf(want); shared { - return own - } - return "" -} - func oneOf(list []string, s string) bool { for _, x := range list { if x == s { diff --git a/go/catalogue/co_located_test.go b/go/catalogue/co_located_test.go deleted file mode 100644 index 340a813..0000000 --- a/go/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/go/catalogue/collections.go b/go/catalogue/collections.go index 178bcfb..ce2ab2c 100644 --- a/go/catalogue/collections.go +++ b/go/catalogue/collections.go @@ -3,10 +3,6 @@ package catalogue import ( "fmt" "path" - "regexp" - "slices" - "sort" - "strconv" "strings" ) @@ -37,39 +33,9 @@ func ItemKey(collection, name string) string { func (it Item) key() string { return ItemKey(it.Collection, it.Name) } -// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault. -func (it Item) order() int { - if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 { - return n - } - return OrderDefault -} - // OrderAttribute is the attribute that orders items. const OrderAttribute = "order" -// matches is whether the item's attributes carry every pair of the selector. -func (it Item) matches(where map[string]string) bool { - for k, v := range where { - if got, has := it.Attributes[k]; !has || got != v { - return false - } - } - return true -} - -// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in. -func itemsInOrder(items []Item) []Item { - out := append([]Item(nil), items...) - sort.SliceStable(out, func(a, b int) bool { - if oa, ob := out[a].order(), out[b].order(); oa != ob { - return oa < ob - } - return out[a].Name < out[b].Name - }) - return out -} - // Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item // is introduced by ` `, and by nothing without one. type Collection struct { @@ -86,120 +52,6 @@ const CollectedNamespace = "collected" // `${collected:environment:systemd}`, which is not a selector. const EnvironmentCollection = "environment" -// oldCollectionNamespaces are the namespaces read as collections of their own name. -var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} - -var ( - // ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. - ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) - attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) -) - -// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is -// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon -// before the first one. -func splitSelector(name string) (collection, selector string) { - eq := strings.Index(name, "=") - if eq < 0 { - return name, "" - } - colon := strings.LastIndex(name[:eq], ":") - if colon < 0 { - return name, "" - } - return name[:colon], name[colon+1:] -} - -// selectorOf is a selector's pairs, or why it is none. -func selectorOf(selector string) (map[string]string, error) { - out := map[string]string{} - if selector == "" { - return out, nil - } - for _, pair := range strings.Split(selector, ",") { - k, v, ok := strings.Cut(pair, "=") - switch { - case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v): - return nil, fmt.Errorf("%q in %q is not =", pair, selector) - case out[k] != "": - return nil, fmt.Errorf("%q names %s twice", selector, k) - } - out[k] = v - } - return out, nil -} - -// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one. -func collectionOfPlaceholder(namespace, key string) (string, bool) { - key, _ = splitSelector(key) - switch { - case namespace == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"): - // The environment is one collection, and the rest of the name picks the rendering (issue 795). - return EnvironmentCollection, true - case namespace == CollectedNamespace: - return key, true - case namespace == "environment": - return EnvironmentCollection, true - case slices.Contains(oldCollectionNamespaces, namespace): - return namespace + ":" + key, true - } - return "", false -} - -// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it. -// ok is false for a name that is not an old namespace's. -// -// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data, -// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3). -func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) { - namespace, key, _ := strings.Cut(name, ":") - if !slices.Contains(oldCollectionNamespaces, namespace) { - return "", false, nil - } - switch namespace { - case "shell": - target, slot, two := strings.Cut(key, ":") - if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { - return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+ - "the session's file one of %s or the power moment one of %s, and the slot one of %s", name, - strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "), - strings.Join(knownSlots, ", ")) - } - // shellCode's line before each module's code. - return "#", true, nil - case "environment": - if key == "" { - // The collection itself, which an item is contributed to; a placeholder names a rendering. - return "#", true, nil - } - if key != EnvironmentPOSIX && key != EnvironmentSystemd { - return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", - name, EnvironmentPOSIX, EnvironmentSystemd) - } - return "#", true, nil - } - seat, kind, _ := strings.Cut(key, ":") - s, r, ok := receivable(seat, kind) - switch { - case !ok && s.Name == "": - return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat) - case !ok: - return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s)) - case r.Shape != nil && (forItem || collectableAsText(r)): - return r.Comment, true, nil - case r.Shape != nil: - return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+ - "(novox/hq ADR 0255)", name, s.Name) - case r.Dirs: - return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+ - "the controller fills; it is not yet a collection (design 57 L1)", name) - case s.Name != canonicalSeat(seat): - return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name) - } - return r.Comment, true, nil -} - // itemTemplateProblem is what is wrong with an item's template path, or "". func itemTemplateProblem(m Manifest, name string) string { dir := m.TemplatesDir() @@ -210,157 +62,3 @@ func itemTemplateProblem(m Manifest, name string) string { } return "" } - -// isOld is whether a collection's name is an old namespace's. -func isOld(name string) bool { - _, old, _ := oldCollection(name, true) - return old -} - -// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content, -// and naming a collection the module declares it collects. -func collectedPlaceholderProblems(m Manifest, r map[string]any) []string { - var problems []string - for _, field := range sortedKeys(r) { - v, ok := r[field].(string) - if !ok { - continue - } - for _, f := range ofCollected.FindAllStringSubmatch(v, -1) { - base, selector := splitSelector(f[1]) - if _, err := selectorOf(selector); err != nil { - problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) - continue - } - switch { - case field != "content": - problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+ - "only in a file's content", m.Module, r["id"], f[0], field)) - case isOld(base): - if _, _, wrong := oldCollection(base, false); wrong != nil { - problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong)) - } - case !slices.ContainsFunc(m.Collections, func(c Collection) bool { return c.Name == base }): - problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+ - "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base)) - } - } - } - return problems -} - -// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's. -func collectionComment(m Manifest, name string) (string, error) { - if comment, old, wrong := oldCollection(name, false); old { - return comment, wrong - } - for _, c := range m.Collections { - if c.Name == name { - return c.Comment, nil - } - } - return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name) -} - -// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these -// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not -// wanted here. -func itemsLeftOutFor(modules []Manifest, holder Manifest, collection string, where map[string]string, - facts map[string]string, caps map[string]bool) []string { - var out []string - for _, m := range inModuleOrder(modules) { - for _, it := range itemsInOrder(m.Items) { - if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) { - continue - } - if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) { - continue - } - out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+ - "machine, from contributions, and an item only by the provisioner's client (design 57 L1)", - m.Module, it.key(), holder.Module)) - } - } - return out -} - -// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is -// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17). -func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanCollection) []string { - var out []string - for _, c := range collections { - namespace, key, _ := strings.Cut(c.Name, ":") - for _, m := range inModuleOrder(modules) { - old := false - switch namespace { - case "shell": - target, slot, _ := strings.Cut(key, ":") - old = slices.ContainsFunc(m.Shell, func(s ShellCode) bool { return s.For == target && s.Slot == slot }) - case "environment": - old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0) - case "contribution": - seat, kind, _ := strings.Cut(key, ":") - old = slices.ContainsFunc(m.allContributions(), func(s SeatContribution) bool { - return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind - }) - } - if old { - out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+ - "this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name, - holder.Module, m.Module)) - } - } - } - return out -} - -// renderedOldWay is whether the controller renders a collection's items into a collector not handed. -func renderedOldWay(collection string) bool { - namespace, _, _ := strings.Cut(collection, ":") - return namespace == "shell" || namespace == "contribution" || namespace == EnvironmentCollection -} - -// heldItem is one item of a module rendered the old way. -type heldItem struct { - item Item - text string -} - -// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by -// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so). -func heldItems(m Manifest, collection string, where map[string]string, facts map[string]string, - caps map[string]bool) []heldItem { - if !renderedOldWay(collection) { - return nil - } - var out []heldItem - for _, it := range itemsInOrder(m.Items) { - if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) { - continue - } - if text, ok := heldText(it, facts); ok { - out = append(out, heldItem{item: it, text: text}) - } - } - return out -} - -// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh -// does not hold or which names anything but a machine fact this machine has. -func heldText(it Item, facts map[string]string) (string, bool) { - if it.Content == "" { - return "", false - } - text := it.Content - for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) { - value, has := facts[f[2]] - if f[1] != "machine" || !has { - return "", false - } - text = strings.ReplaceAll(text, f[0], value) - } - if !strings.HasSuffix(text, "\n") { - text += "\n" - } - return text, true -} diff --git a/go/catalogue/collections_bar_test.go b/go/catalogue/collections_bar_test.go deleted file mode 100644 index 54de9d6..0000000 --- a/go/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/go/catalogue/collections_l2_test.go b/go/catalogue/collections_l2_test.go deleted file mode 100644 index c16c4a0..0000000 --- a/go/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/go/catalogue/collections_l3_test.go b/go/catalogue/collections_l3_test.go index 6935def..631fc67 100644 --- a/go/catalogue/collections_l3_test.go +++ b/go/catalogue/collections_l3_test.go @@ -1,131 +1,14 @@ 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 := decodeForTest([]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) - } - } -} - -// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item -// name) and with the same lines as the contributions they replace. -func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) { - piece := func(order, command string) string { - return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n" - } - asOld := Manifest{Module: "power", Contributions: []SeatContribution{ - block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`), - block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`), - block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`), - }} - asItems := Manifest{Module: "power", Moved: true, Items: []Item{ - {Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"), - Attributes: map[string]string{"bar": "bottom", "place": "status"}}, - {Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"), - Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, - {Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"), - Attributes: map[string]string{"bar": "top", "place": "resources"}}, - {Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery", - Attributes: map[string]string{"bar": "bottom", "place": "status"}}, - }} - other := Manifest{Module: "another", Contributions: []SeatContribution{ - block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}} - for _, caps := range []map[string]bool{nil, {"battery": true}} { - was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, - Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom") - is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, - Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom") - // The old form puts the contributions of a module under one line naming it and the item form puts one under - // each item: the same pieces in the same order, otherwise the same bytes. - want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") + - "# power\n" + piece("", "one") - if caps["battery"] { - want += "# power\nx\n" - } - want += "own-last\n" - if is != want { - t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want) - } - if !strings.Contains(was, piece("", "two")) { - t.Errorf("the contributions made\n%s", was) - } - } - if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) { - t.Errorf("the top bar renders\n%s", got) - } -} diff --git a/go/catalogue/collections_test.go b/go/catalogue/collections_test.go index c0172b1..e1ad8e4 100644 --- a/go/catalogue/collections_test.go +++ b/go/catalogue/collections_test.go @@ -1,9 +1,7 @@ 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 @@ -32,65 +30,3 @@ func builtWith(module string, texts map[string]string) []Built { 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 := decodeForTest([]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) - } -} diff --git a/go/catalogue/computed_test.go b/go/catalogue/computed_test.go deleted file mode 100644 index 3b46312..0000000 --- a/go/catalogue/computed_test.go +++ /dev/null @@ -1,192 +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 TestSettingsApplyToAComputedModuleToo(t *testing.T) { - // Being computed is about where the files come from, not about whether they are configurable. - // A line drawn there would be arbitrary and nobody could predict it. - got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{}) - gen := &fake{on: map[string]bool{"workstation": true}} - out, err := got.Declaration(Rendering{ - Generators: map[string]Generator{"mesh-network": gen}, - Settings: SettingsBy{"mesh-network": {{From: "the mesh", - Values: map[string]any{"keepalive": 25}}}}, - }) - if err != nil { - t.Fatal(err) - } - content := out[0]["content"].(string) - if !strings.Contains(content, `"keepalive": 25`) { - t.Fatalf("the setting did not reach a computed file: %s", content) - } - if !strings.Contains(content, `"peer": "workstation"`) { - t.Fatalf("the setting replaced what the generator computed: %s", content) - } -} - -func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) { - // WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning - // both is caught rather than producing a machine on two networks that each half-work. - _, err := Resolve(shelf( - Manifest{Module: "mesh-network", Computed: "mesh-network", - Provides: Offers("private-network")}, - Manifest{Module: "tailscale", Provides: Offers("private-network")}, - Manifest{Module: "backups", Requires: []string{"private-network"}}, - ), []string{"backups"}, workstation(), World{}) - - if err == nil { - t.Fatal("two answers to one requirement were taken silently") - } - for _, want := range []string{"mesh-network", "tailscale", "private-network"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal does not name %s: %v", want, err) - } - } -} - -// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no -// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file -// written into, and the runtime reloaded on it. -type reloading struct{} - -func (reloading) Resources(node string) ([]map[string]any, bool, error) { - return []map[string]any{ - {"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json", - "merge": MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`}, - {"id": "registry-trust-reload", "type": "service", "unit": "docker.service", - "state": "running", "reload-on": []string{"registry-trust"}}, - }, true, nil -} - -func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) { - // Ids are prefixed with their module on the way out. An unprefixed reload-on would name a - // resource the host never sees, and the runtime would never be reloaded for its trust. - got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": reloading{}}}) - if err != nil { - t.Fatal(err) - } - var file, service map[string]any - for _, r := range out { - switch r["type"] { - case "file": - file = r - case "service": - service = r - } - } - if file == nil || service == nil { - t.Fatalf("got %v", out) - } - if file["into"] != "json" { - t.Errorf("into did not reach the host: %v", file) - } - if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want { - t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"]) - } -} diff --git a/go/catalogue/config_rename_test.go b/go/catalogue/config_rename_test.go index 6efb5a7..757a1cd 100644 --- a/go/catalogue/config_rename_test.go +++ b/go/catalogue/config_rename_test.go @@ -21,68 +21,6 @@ func renaming() map[string]any { 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) - } -} - func listHolds(list []string, s string) bool { for _, x := range list { if x == s { diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go index 47efa8f..338c72a 100644 --- a/go/catalogue/config_test.go +++ b/go/catalogue/config_test.go @@ -3,7 +3,6 @@ package catalogue import ( "bytes" "encoding/json" - "errors" "strings" "testing" ) @@ -147,136 +146,6 @@ func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) { } } -// 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 := decodeForTest([]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 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) - } - - // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}). - withRoute := inConfigForm() - withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}} - routed, err := parseRaw(t, withRoute) - if err != nil { - t.Fatal(err) - } - _, err = Resolution{}.composed(routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route") - if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(), - `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") { - t.Fatalf("a contribution's unset value is not said in the config form's words: %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) - } -} - // 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed. func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) { m := map[string]any{ diff --git a/go/catalogue/consumer_into_serves.go b/go/catalogue/consumer_into_serves.go index 2923fa5..1e87274 100644 --- a/go/catalogue/consumer_into_serves.go +++ b/go/catalogue/consumer_into_serves.go @@ -1,114 +1,9 @@ package catalogue import ( - "fmt" - "regexp" "sort" - "strings" ) -// consumerFact is `${consumer:}` or `${consumer::}`. -var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) - -// consumerFacts are what a served value may name about the consumer it is being derived for. -// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose, -// so it is the one thing the mesh can hand to a provider without either end guessing. -var consumerFacts = []string{"as"} - -// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own -// identifier with its separator written `-` instead of `_` — the whole of the difference between -// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept. -var consumerAlphabets = []string{"dns"} - -// ServedTo fills a provider's served values for one consumer. -// -// `as` is the identity the mesh minted for that consumer — the same string it is told to present -// as a login. Values with no placeholder are returned exactly as they were, and a block with no -// placeholder at all is returned unchanged, so this costs nothing for the providers that derive -// nothing. -// -// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider -// stated outright, and is left alone. -func ServedTo(serves map[string]any, as string) (map[string]any, error) { - if len(serves) == 0 { - return serves, nil - } - var out map[string]any - for _, key := range sortedAnyKeys(serves) { - text, ok := serves[key].(string) - if !ok || !strings.Contains(text, "${consumer:") { - continue - } - filled, err := consumerInto(text, as) - if err != nil { - return nil, fmt.Errorf("the value served as %q: %w", key, err) - } - if out == nil { - // Copied only once something actually changes: the caller's map is the manifest's, - // and a provider that derives nothing must not have it rewritten underneath it. - out = make(map[string]any, len(serves)) - for k, v := range serves { - out[k] = v - } - } - out[key] = filled - } - if out == nil { - return serves, nil - } - return out, nil -} - -// consumerInto replaces every `${consumer:…}` in one value. -// -// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through, -// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name, -// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}` -// is refused by (boundInto). -func consumerInto(value, as string) (string, error) { - var failed error - out := consumerFact.ReplaceAllStringFunc(value, func(match string) string { - parts := consumerFact.FindStringSubmatch(match) - fact, alphabet := parts[1], parts[2] - if fact != "as" { - if failed == nil { - failed = fmt.Errorf( - "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts)) - } - return match - } - switch alphabet { - case "": - return as - case "dns": - return asDNSLabel(as) - default: - if failed == nil { - failed = fmt.Errorf( - "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets)) - } - return match - } - }) - if failed != nil { - return "", failed - } - return out, nil -} - -// asDNSLabel writes a minted identity as a DNS label. -// -// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and -// already inside the bound of the provision serving them: a provider that serves one as a DNS label -// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without -// saying is held to twenty (IdentityBoundOf). So -// this is the separator and nothing else — no lower-casing of what is already lower case, no -// truncation to a limit the identity is already inside, no padding of a name that is already long -// enough. Each of those would be the mesh guessing at a rule it has not been given. -func asDNSLabel(as string) string { - return strings.ReplaceAll(as, "_", "-") -} - func sortedAnyKeys(values map[string]any) []string { out := make([]string, 0, len(values)) for k := range values { @@ -117,149 +12,3 @@ func sortedAnyKeys(values map[string]any) []string { sort.Strings(out) return out } - -// derivedFor is what the provider on this machine derives for one consumer of one provision -// (novox/hq ADR 0202). -// -// Settled first, then derived: an operator may set a prefix on what the provider serves and the -// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the -// consumer are returned — the rest of a `serves` block is the same for every consumer and is -// already in the provider's own definition, so repeating it here would be a second copy to go -// stale. -// -// The first module in the resolved order that says it serves the provision answers, which is the -// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is -// not an error: a contribution can reach a machine whose provider is a record or an adapter, and -// then there is nothing derived to tell. -func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) { - for _, m := range r.Modules { - serves, said := m.Serves[provision] - if !said { - continue - } - var names map[string]any - for key, value := range serves { - if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") { - if names == nil { - names = map[string]any{} - } - names[key] = value - } - } - if names == nil { - return nil, nil - } - // **A consumer that keeps several holders of this provision is refused** — this is issue - // 124's own failure one case to the side, and it would be just as quiet. - // - // Each holder gets its own login, `…_` (ADR 0094), and a provider derives from the - // login, so it would make one resource per holder. The consumer's side has no such - // dimension: one binding file per provision, one `${bound::}`, both - // derived from the un-suffixed identity. So the provider would create the holder's - // resource and the consumer would be configured against a name nothing made — it would - // authenticate successfully and be refused on every object, which reads like a credential - // fault and is not one. - // - // Lifting this means giving the consumer's side a local dimension. That is a decision, - // not an omission, and until it is taken the mesh says so rather than guessing. - if local != "" { - return nil, fmt.Errorf( - "%s keeps several holders of %s (this one is %q), and %s derives %s for each "+ - "consumer from the login the mesh minted. Each holder has its own login, and a "+ - "consumer is told one value per requirement — so the two ends would name "+ - "different things and nothing would compare them (novox/hq ADR 0202)", - consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names))) - } - settled, err := Settle(names, WithDefaults(m, settings[m.Module])) - if err != nil { - return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) - } - derived, err := ServedTo(settled, as) - if err != nil { - return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err) - } - return derived, nil - } - return nil, nil -} - -// notTranscribed refuses a consumer's file that writes out the value its provider derives for it, -// instead of asking for it (novox/hq ADR 0202, issue 124). -// -// **What would have caught the one wrong instance.** The object store's three consumers each wrote -// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and -// nothing compared it to what the provider would actually create: the module would have -// authenticated successfully and been refused on every object, which reads like a credential fault -// and is not one. It looked authoritative for months. -// -// The test is exact and costs one string search: a definition whose file already contains the -// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a -// coincidence — a derived value carries the identity the mesh minted for this very consumer on -// this very machine, which nothing else would spell out — and it cannot be checked afterwards, -// because after substitution every consumer's file contains it legitimately. -// -// Only values that actually name the consumer are judged. A provider that serves a constant under -// the same key serves the same constant to everyone, and a consumer repeating it is redundant -// rather than wrong. -func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error { - if fmt.Sprint(resource["type"]) != "file" { - return nil - } - content, ok := resource["content"].(string) - if !ok || content == "" { - return nil - } - for _, provision := range sortedKnown(known) { - values := known[provision] - identity := values["as"] - if identity == "" { - continue - } - for _, key := range sortedStringKeys(values) { - if key == "as" { - // The login is not derived from itself, and a consumer that must present it in a - // connection string legitimately has it from `${bound:…}` — which is what it will - // be after substitution, so this would judge the substitution, not the module. - continue - } - value := values[key] - if value == "" || !namesTheConsumer(value, identity) { - continue - } - if !strings.Contains(content, value) { - continue - } - return fmt.Errorf( - "%s writes %q into %v, and that is exactly what %s derives for it — a definition "+ - "keeping its own copy of somebody else's naming rule is one that can disagree "+ - "with it, silently. Say ${bound:%s:%s} and be told", - module, value, resource["id"], provision, provision, key) - } - } - return nil -} - -// namesTheConsumer is whether a derived value was built from this consumer's identity — in the -// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived -// from it, whatever else it may be. -func namesTheConsumer(value, identity string) bool { - return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity)) -} - -func sortedKnown(known map[string]map[string]string) []string { - out := make([]string, 0, len(known)) - for k := range known { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func sortedStringKeys(values map[string]string) []string { - out := make([]string, 0, len(values)) - for k := range values { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/go/catalogue/consumers.go b/go/catalogue/consumers.go index 6ad2e69..2666a3d 100644 --- a/go/catalogue/consumers.go +++ b/go/catalogue/consumers.go @@ -1,101 +1,4 @@ package catalogue -import ( - "fmt" - "regexp" - "slices" -) - // ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:}`. const ConsumersNamespace = "consumers" - -// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is -// refused rather than passed over. -var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`) - -// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content, -// for a provision the module provides, in a file only the provider reads and the client writes back. -func consumersPlaceholderProblems(m Manifest, r map[string]any) []string { - var problems []string - for _, field := range sortedKeys(r) { - v, ok := r[field].(string) - if !ok { - continue - } - for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) { - switch { - case field != "content" || fmt.Sprint(r["type"]) != "file": - problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+ - "are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field)) - continue - case !slices.ContainsFunc(m.Provides, func(o Offer) bool { return o.Name == f[1] }): - problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+ - "provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0], - m.Module, f[1])) - } - if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" { - problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+ - "mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0], - r["mode"])) - } - if owner := ownershipOf(r); owner != RuntimeOwned { - problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+ - "%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module, - r["id"], f[0], owner)) - } - } - } - return problems -} - -// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's -// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never -// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is -// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780). -// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine -// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged -// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged). -func consumersHandedOnly(m Manifest, r map[string]any, with Rendering) (judged, leftOut bool) { - content, _ := r["content"].(string) - switch { - case ofConsumers.FindString(content) == "": - return false, false - case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "": - return true, false - } - return false, true -} - -// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed. -func consumersLeftOutLine(m Manifest, r map[string]any) string { - return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+ - "(novox/hq ADR 0373)", m.Module, r["id"]) -} - -// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out. -func consumersNotHanded(m Manifest, r map[string]any, with Rendering) (judged bool, err error) { - judged, left := consumersHandedOnly(m, r, with) - if left { - return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+ - "handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+ - "consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module) - } - return judged, nil -} - -// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's -// resource as if its text were inline. -func consumersOfTemplate(m Manifest, id, text string) []string { - for _, r := range m.Resources { - if fmt.Sprint(r["id"]) != id { - continue - } - withText := map[string]any{} - for k, v := range r { - withText[k] = v - } - withText["content"] = text - return consumersPlaceholderProblems(m, withText) - } - return nil -} diff --git a/go/catalogue/contributes_test.go b/go/catalogue/contributes_test.go index 19db70d..b2be05e 100644 --- a/go/catalogue/contributes_test.go +++ b/go/catalogue/contributes_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) @@ -14,210 +13,6 @@ import ( // 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) - } -} - // 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. @@ -230,23 +25,6 @@ func provider() Manifest { } } -// 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 { @@ -264,125 +42,3 @@ func grantedTo(t *testing.T, out []map[string]any) []Contribution { t.Fatalf("the provider was given no manifest: %v", out) return nil } - -func TestAProviderIsToldAboutConsumersOnOtherMachines(t *testing.T) { - // A database told to create a password and not who for can do nothing with it. - given := grantedTo(t, oneGrant(t)) - if len(given) != 1 { - t.Fatalf("got %v", given) - } - if given[0].Node != "workstation" || given[0].From != "meshboard" { - t.Fatalf("it does not say who asked: %v", given[0]) - } - if given[0].Values["name"] != "meshboard" { - t.Fatalf("it does not say what was asked for: %v", given[0].Values) - } -} - -func TestTheManifestNamesTheFileRatherThanCarryingTheCredential(t *testing.T) { - // The mesh discarded the value and could not put it here if it wanted to. What is here is - // where to find it — and the readable half therefore stays readable. - out := oneGrant(t) - given := grantedTo(t, out) - // Named after the machine and the module, because a consumer is both (novox/hq - // 04-ISSUES/022). The machine alone, two modules on one node wrote to one path. - if given[0].Secret != "/var/lib/postgres/grants/workstation.meshboard.secret" { - t.Fatalf("the manifest does not name the credential's file: %q", given[0].Secret) - } - for _, r := range out { - if r["path"] != "/var/lib/postgres/grants/mesh.json" { - continue - } - if strings.Contains(r["content"].(string), "c2VhbGVk") { - t.Fatal("the readable manifest carries the sealed credential") - } - } -} - -func TestTheCredentialItselfLandsSealedBesideIt(t *testing.T) { - for _, r := range oneGrant(t) { - if r["path"] != "/var/lib/postgres/grants/workstation.meshboard.secret" { - continue - } - if r["sealed"] != "c2VhbGVk" { - t.Fatalf("got %v", r) - } - if r["content"] != nil { - t.Fatal("a credential was written in the clear") - } - return - } - t.Fatal("no credential file") -} - -func TestAConsumersOwnContributionsAreStillThere(t *testing.T) { - // Cross-node grants are merged in with this machine's own, because from the provider's side - // they are the same thing — somebody wanting something — and a provider that had to read two - // lists would read one of them. - got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), - []string{"board"}, reachable(), World{}) - if err != nil { - t.Fatal(err) - } - given := received(t, mustDeclare(t, got)) - if len(given) != 1 || given[0].Node != "" { - t.Fatalf("a local contribution grew a node: %v", given) - } -} - -func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) { - // A consumer that requires a parameterless provision (one whose serves names no consumer key — - // redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be - // granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as - // withdrawn — so the provider never created the account and the consumer authenticated nowhere. - r := Resolution{ - Node: "laptop", - Modules: []Manifest{{ - Module: "amqp-ping", - Requires: []string{"amqp"}, - }}, - } - values, asks, err := r.ContributionsFrom("amqp", "amqp-ping", SettingsBy{}) - if err != nil { - t.Fatal(err) - } - if !asks { - t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned") - } - if values == nil { - t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values) - } -} - -func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) { - // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for, - // and it can only do that if the mesh stops asking — a consumer that was unassigned would - // otherwise keep a working login for ever, and nothing would say so. - // - // A grant with no asking module is exactly that state: the mesh still holds the secret, - // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it. - got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(Rendering{Grants: []Grant{ - {Provision: "postgres-database", Consumer: "still-here", From: "meshboard", - Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"}, - {Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="}, - }}) - if err != nil { - t.Fatal(err) - } - - given := grantedTo(t, out) - if len(given) != 1 || given[0].Node != "still-here" { - t.Fatalf("the provider is still told about a machine that stopped asking: %v", given) - } - // And no credential is written for it either, or the provisioner would find a file for a - // consumer its manifest does not mention and have to guess what that means. - for _, r := range out { - if path, _ := r["path"].(string); strings.Contains(path, "gone-away") { - t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r) - } - } -} diff --git a/go/catalogue/contribution_dependencies_test.go b/go/catalogue/contribution_dependencies_test.go deleted file mode 100644 index 39ad0a9..0000000 --- a/go/catalogue/contribution_dependencies_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package catalogue - -import ( - "reflect" - "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 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) - } -} diff --git a/go/catalogue/core_credentials.go b/go/catalogue/core_credentials.go index 1b43550..bf8a374 100644 --- a/go/catalogue/core_credentials.go +++ b/go/catalogue/core_credentials.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "slices" ) // CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and @@ -11,18 +10,9 @@ import ( // for these alone. var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"} -// CarriesVaultSealed says whether module's own secrets are the core's credentials. -func CarriesVaultSealed(module string) bool { return slices.Contains(CoreCredentialModules, module) } - // VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed). const VaultSealedField = "vault-sealed" // ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept // yet: the send asks the vault for it. Never sent. var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`) - -// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one. -func (with Rendering) vaultSealedOf(module, name string) (json.RawMessage, bool) { - box, ok := with.VaultSealed[module][name] - return box, ok && len(box) > 0 -} diff --git a/go/catalogue/core_credentials_test.go b/go/catalogue/core_credentials_test.go index 7e09a7d..904572b 100644 --- a/go/catalogue/core_credentials_test.go +++ b/go/catalogue/core_credentials_test.go @@ -2,8 +2,6 @@ package catalogue import ( "encoding/json" - "reflect" - "strings" "testing" ) @@ -11,35 +9,10 @@ import ( // 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 := decodeForTest([]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 { @@ -51,57 +24,3 @@ func brokerFile(t *testing.T, c Composed) map[string]any { } var aVaultBox = json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Yw=="}`) - -func TestACoreCredentialCarriesTheVaultsBoxAndNoSealOfTheControllers(t *testing.T) { - r := clientOn(t) - with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}, - VaultSealed: map[string]map[string]json.RawMessage{"mesh-provisioner-client": {"provisioner-broker": aVaultBox}}} - c, got := composeWith(t, r, with) - f := brokerFile(t, c) - if f == nil { - t.Fatalf("the credential's file is not declared: %s", got) - } - if f["sealed"] != nil || !reflect.DeepEqual(f["vault-sealed"], aVaultBox) || strings.Contains(got, `"nacl"`) { - t.Fatalf("the file is %v: the vault's box alone, never the controller's seal", f) - } - // Without the controller's own value at all: the vault's box is enough. - with.Needed = nil - if c, _ := composeWith(t, r, with); brokerFile(t, c) == nil { - t.Fatal("a core credential with the vault's box and no seal of the controller's was refused or dropped") - } -} - -func TestACoreModuleTheVaultGaveNothingForIsLeftOutWithWhy(t *testing.T) { - r := clientOn(t) - c, got := composeWith(t, r, Rendering{NotPlaced: map[string]string{ - "mesh-provisioner-client": "the vault did not answer for its credential"}}) - if brokerFile(t, c) != nil || strings.Contains(got, "mesh-provisioner-client.") { - t.Fatalf("a core module the vault gave nothing for was placed: %s", got) - } - if !strings.Contains(c.LeftOut["mesh-provisioner-client"], "the vault did not answer") { - t.Fatalf("left out %v", c.LeftOut) - } -} - -// An engine below VaultBoxContract is rendered with neither field: its composition is the controller's sealed value, -// byte for byte what it was. -func TestWithoutTheVaultsBoxTheCompositionIsAsBefore(t *testing.T) { - r := clientOn(t) - with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}} - before, _ := composeWith(t, r, with) - with.VaultSealed, with.NotPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} - after, _ := composeWith(t, r, with) - if !reflect.DeepEqual(before, after) || brokerFile(t, before)["sealed"] != "nacl" { - t.Fatalf("the composition changed: %v", brokerFile(t, after)) - } -} - -// Asked ahead of a send that has no box kept yet: a stand-in, named as foreseen, never sent. -func TestAForeseenVaultBoxIsNamed(t *testing.T) { - r := clientOn(t) - c, _ := composeWith(t, r, Rendering{VaultSealed: map[string]map[string]json.RawMessage{ - "mesh-provisioner-client": {"provisioner-broker": ForeseenVaultBox}}}) - if !reflect.DeepEqual(c.Foreseen, []string{"mesh-provisioner-client/provisioner-broker"}) { - t.Fatalf("foreseen %v", c.Foreseen) - } -} diff --git a/go/catalogue/data_placed.go b/go/catalogue/data_placed.go index 673c655..313b791 100644 --- a/go/catalogue/data_placed.go +++ b/go/catalogue/data_placed.go @@ -64,29 +64,3 @@ func ParseDataItems(text string) (map[string][]PlacedDataItem, error) { } return out, nil } - -// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text -// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is -// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out. -func (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) { - left := r.LeftOut(with.Settings, with.Adopted) - for module, why := range with.NotPlaced { - if _, already := left[module]; !already { - left[module] = why - } - } - modules := make([]Manifest, 0, len(r.Modules)) - for _, m := range r.Modules { - if _, isLeft := left[m.Module]; isLeft { - m = backupView(m) - } - modules = append(modules, m) - } - text, unplaced, err := seatContributions(modules, Manifest{}, BackupSeat+":"+BackupKindData, with, - machineFacts(r, with.Names, with.MeshRange), r.Capabilities) - if err != nil { - return nil, unplaced, err - } - items, err = ParseDataItems(text) - return items, unplaced, err -} diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index 4788692..49e7254 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -2,8 +2,6 @@ package catalogue import ( "encoding/json" - "errors" - "fmt" "sort" "strconv" "strings" @@ -284,48 +282,6 @@ type Rendering struct { // (novox/hq ADR 0266). const RootField = "root" -// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a -// machine where agents run as the operator) or when the engine is older than the field and parses -// strictly; kept as "never" otherwise. -func rootInto(resource map[string]any, with Rendering) { - if resource["type"] != "user" { - return - } - value, has := resource[RootField] - if !has { - return - } - if s, _ := value.(string); s == "" || !with.JudgesRoot { - delete(resource, RootField) - } -} - -// machinePort is where a module's port lives on this machine, or the port itself when the mesh has -// not been asked. Unassigned is not an error here: a module with no `listens` never needed one, -// and a caller composing a declaration without a store still gets something coherent. -func (r Rendering) machinePort(module string, wanted int) int { - if at, given := r.Given[module][wanted]; given { - return at - } - if at, known := r.Ports[module][wanted]; known { - return at - } - return wanted -} - -// Declaration is everything the resolved modules put on the node, with settings applied. -// -// Resource identities are prefixed with the module they came from. Two modules may reasonably -// both call something "config", and without this the second would silently replace the first — -// the node applying one of them and reporting success. -func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { - composed, err := r.Compose(with) - if err != nil { - return nil, err - } - return composed.Resources, nil -} - // Composed is a declaration's resources and which module each came from. // // Owner is kept beside the resources because a resource id cannot be split back into its module: @@ -367,1144 +323,8 @@ type Composed struct { // the send. Not a sealed value: nothing composed with it may be sent. const ForeseenSealed = "foreseen: made by the next send" -// NotMadeError is a module's own secret the composition was given no value for (novox/hq issue 275): -// typed, so that a caller can tell "a send would make this, and none has yet" from a composition that -// fails for any other reason without reading the words. -type NotMadeError struct { - Module, Name string -} - -func (e *NotMadeError) Error() string { - return fmt.Sprintf("%s needs a secret called %q and none was made for it", e.Module, e.Name) -} - -// LeftOut is which of this machine's modules a declaration composed with these settings leaves -// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer -// compose. Empty when every module composes. The same judgement SetSettings makes before storing. -func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string { - out := map[string]string{} - for module, why := range r.LeftOutBecause(settings, adopted) { - out[module] = why.Error() - } - return out -} - -// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody -// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue -// 380). An UnreadManifestError for a stored manifest this controller cannot read whole. -func (r Resolution) LeftOutBecause(settings SettingsBy, adopted bool) map[string]error { - out := map[string]error{} - for _, m := range r.Modules { - if why := UnknownFieldReason(m); why != "" { - out[m.Module] = &UnreadManifestError{Module: m.Module, said: why} - continue - } - if err := JudgeSettings(m, settings[m.Module], adopted); err != nil { - out[m.Module] = err - } - } - return out -} - -// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know -// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine. -type UnreadManifestError struct { - Module string - said string -} - -func (e *UnreadManifestError) Error() string { return e.said } - -// Compose is Declaration with the owner of every resource said. -func (r Resolution) Compose(with Rendering) (Composed, error) { - owner := map[string]string{} - received := map[string]map[string][]Contribution{} - leftOut := map[string]string{} - var foreseen, unplaced, handedOnly []string - handed := &handedFiles{plans: map[string]Plan{}} - resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed) - if err != nil { - return Composed{}, err - } - sort.Strings(foreseen) - if with.BusMembership != "" { - // The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a - // secret and placed where the host looks for exactly this (design 28, task 5.2). - resources = append(resources, map[string]any{ - "id": BusMembershipID(), "type": "file", "path": BusMembershipPath, - "sealed": with.BusMembership, "mode": "0600", - }) - } - sort.Strings(unplaced) - sort.Strings(handedOnly) - out := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, - Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths} - if plans := handed.finished(r.Needs); len(plans) > 0 { - out.Plans = plans - } - return out, nil -} - -// BusMembershipID names the resource carrying a machine's membership for the new bus, and -// BusMembershipPath is where the host reads it — the same constant on both sides. -func BusMembershipID() string { return "bus-membership" } - const BusMembershipPath = "/var/lib/mesh/membership-next.json" -func (r Resolution) compose(with Rendering, owner map[string]string, - received map[string]map[string][]Contribution, leftOut map[string]string, - foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) { - // **A setting is judged where it is stored, and an impossible one costs a module, not a - // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes - // this module uncomposable; it is left out of the declaration — its held things kept, its - // containers untouched, the machine told so by name — and the machine is told everything else. - // Before placing, because a placement is a setting too. - left := r.LeftOut(with.Settings, with.Adopted) - kept := make([]Manifest, 0, len(r.Modules)) - var stillBackedUp []Manifest - for module, why := range with.NotPlaced { - if _, already := left[module]; !already { - left[module] = why - } - } - for _, m := range r.Modules { - if why, isLeft := left[m.Module]; isLeft { - if leftOut != nil { - leftOut[m.Module] = why - } - // **Its data is still copied** (novox/hq ADR 0262): a module left out runs nothing new, and - // the data it already holds on the machine is the reason to keep copying it. Only its data, - // as the backup holder's lines are derived from it, and the directories they name. - stillBackedUp = append(stillBackedUp, backupView(m)) - continue - } - kept = append(kept, m) - } - r.Modules = kept - - // Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings, - // credentials and contributions land are resolved against this node's directories, so every - // reader below — the binding files, the sealed secrets, the grant paths a contribution - // names — sees a concrete place and none learns the vocabulary. - // Into a fresh slice, never the caller's: one resolution may compose for many nodes, and a - // slice element written in place would carry the first node's places into the second's. - placed := make([]Manifest, len(r.Modules)) - for i, m := range r.Modules { - var err error - if placed[i], err = placedManifest(m, with); err != nil { - return nil, err - } - } - r.Modules = placed - - // Where each provision's credentials land, so a contribution can name the file rather than - // carry a value the mesh does not have. - directories := map[string]string{} - for _, m := range r.Modules { - for provision, where := range m.Grants { - directories[provision] = where - } - } - given, err := r.contributions(with.Settings, with.Grants, directories) - if err != nil { - return nil, err - } - - // A workload on THIS machine contributes the port it declared, and the machine may have - // published it somewhere else (novox/hq ADR 0066). The mirror of the redirect below: 038 fixed - // what a consumer is TOLD about a provider, and this is what a workload TELLS the provider about - // itself — gitea declaring 3000, published as 20000:3000, and the co-located proxy dialling 3000, - // where nothing listens, for every request. - // - // **The contributing module's assignment, never the provider's.** The port belongs to the - // workload; using the map of whatever answers the requirement would move a route to wherever the - // proxy happens to be published. A contribution carried here from another machine is left exactly - // as it is — its port is that machine's to assign, and this map knows nothing about it. - for provision := range given { - for i := range given[provision] { - said := &given[provision][i] - if said.Node != "" && said.Node != r.Node { - continue - } - said.Values = atMachinePort(said.Values, said.From, with.Ports) - } - } - - // A provider answered on this same machine never passed through the walk that works out what a - // provider on ANOTHER machine serves (novox/hq 04-ISSUES/038, ADR 0066). That walk does two - // things — it derives the served facts with the provider node's port assignments, and it settles - // them with that node's settings layers — and the same-node paths (resolve.go's servedHere, and - // here() below) did neither, because both run while resolving, before either is known. - // - // The port was the first half to be noticed (038). The second half is worse and quieter: a served - // value the OPERATOR supplied — a certificate authority's root, which is the one kind of value a - // manifest cannot carry, because it differs on every mesh — arrived as the manifest's empty - // default. A proxy then wrote an empty CA bundle, fell back to the system trust store, could not - // verify the internal authority, and stopped issuing with nothing saying why. - // - // So it is re-derived here, in full, exactly as the cross-node path derives it. **After the - // closure is known**, which also removes an order dependence: the resolver built these needs - // mid-walk from whichever modules had been chosen by then, so what a co-located binding carried - // depended on the order somebody happened to assign things in. - for i := range r.Needs { - if r.Needs[i].ByRecord || r.Needs[i].From != r.Node { - continue - } - serves, answered, err := r.servedOnThisMachine(r.Needs[i].Name, with) - if err != nil { - return nil, err - } - if answered { - r.Needs[i].Serves = serves - } - } - - // Once, from every module's listens -- not per module. A module receiving only its own ports - // would write a rule set that closed every other module on the machine. Each module's per-node - // exposure settings override its listens' source first (novox/hq ADR 0046). - rules, err := r.Rules(with) - if err != nil { - return nil, err - } - // **A machine that has not said which links face outside is sent no filter** (novox/hq ADR - // 0140). The whole chain is written around those links: with none, the rule that lets this - // machine's own guests keep working would name an empty set, which nftables refuses, and a rule - // set that does not load is a machine filtering nothing while its unit reports success. Refused - // here, where a person reads it, rather than on the machine — and the machine keeps the filter - // it already has. - if filters := r.filtersHere(); filters != "" && len(with.OutwardLinks) == 0 { - return nil, fmt.Errorf( - "%s cannot be sent a filter: it has not reported which of its links face outside, and "+ - "every rule in the chain is written around them. It reports that on each apply; "+ - "`node show %s` says whether it has. Until then %s is not sent, and the machine "+ - "keeps the filter it has", r.Node, r.Node, filters) - } - filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation, - with.OutwardLinks, with.TunnelInterface, with.LocalNetworks) - - // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR - // 0203 §5): the account has one environment, and a machine whose holder arrives later should not - // be the moment two modules are found to disagree about it. - if err := variablesSetOnce(r.Modules); err != nil { - return nil, err - } - - generated, err := r.generatedHere(with) - if err != nil { - return nil, err - } - - var out []map[string]any - for _, m := range r.Modules { - if with.Adopted && m.Filtering != nil { - // Nothing of a module that loads a filter, on an adopted node: its table would drop - // by default and hold accepts, and the found firewall stays in force. Every resource, - // not only the rule set — its service must not run, and a node returned to adopted - // stops it by the ordinary removal of what is no longer declared. - continue - } - resources := m.Resources - - // What the mesh computes for this module goes FIRST, before the module's own resources. - // - // **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so - // whatever the mesh writes down is the order a machine applies. A module's service or - // container routinely depends on one of these files; nothing here ever depends on a - // module's resources, because none of it is computed from them. - // - // Appended, this was wrong in a way that only showed on the first apply and then healed: - // the service started before its certificate or its rule set existed, failed, and the next - // reconcile fixed it. A fault that repairs itself on the second attempt is worse than one - // that does not, because what gets remembered is that it works. - var first []map[string]any - if f := m.Filtering; f != nil { - first = append(first, map[string]any{ - "id": FilteringID(), "type": "file", "path": f.Into, - "content": filtering, "mode": "0600", - }) - } - // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written - // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all - // modules, written by the one that holds the role. - if j := m.Jailing; j != nil { - first = append(first, jailsInto(r.Modules, j)...) - } - if c := m.Certificate; c != nil { - if with.Certificate == "" { - // Asked for and not issued. Refused rather than skipped: a module that serves TLS - // with no certificate does not start, and the reason is somewhere else entirely. - return nil, fmt.Errorf( - "%s wants a certificate for this machine and none was issued", m.Module) - } - first = append(first, map[string]any{ - "id": CertificateID(), "type": "file", "path": c.Into, - // Public. It travels in the open like any other file, because it is a statement - // about a key rather than the key. - "content": with.Certificate, "mode": "0644", - }) - if c.Authority != "" { - first = append(first, map[string]any{ - "id": AuthorityID(), "type": "file", "path": c.Authority, - "content": with.Authority, "mode": "0644", - }) - } - } - if m.BusUsers != "" { - // **The claim authorises it, not the field.** This file holds every user's password - // hash, so a module that could ask for it could read every credential on the bus. - // Checked from this manifest alone, which is the cheapest check there is: whether some - // other module also claims the seat is resolution's business elsewhere, and one holder - // mesh-wide is already guaranteed. - if !m.ClaimsSeat("mesh-broker") { - return nil, fmt.Errorf( - "%s asks for the mesh's user list and does not claim mesh-broker. That file "+ - "holds every user's password hash, so the seat is what authorises it", - m.Module) - } - if with.BusUsers == "" { - // Asked for and not composed. Refused rather than skipped, for the reason a - // certificate is: a bus with no user list refuses every connection in the mesh, and - // an empty file would look like a configuration problem on the machine. - return nil, fmt.Errorf( - "%s holds mesh-broker and the mesh composed no user list, so the bus would "+ - "refuse every connection", m.Module) - } - first = append(first, map[string]any{ - "id": BusUsersID(), "type": "file", "path": m.BusUsers, - "content": with.BusUsers, - // Readable by the server and nothing else. Hashes rather than passwords, so this is - // not a set of working credentials — but a list of every user in the mesh is worth - // keeping to the one process that needs it. - "mode": "0600", - }) - } - // **A secret family's members, as given** (novox/hq ADR 0283): one file each at the family's path, and - // nothing for a member not given — the mesh never makes one, and the module says it waits for it. - for _, name := range sortedKeys(with.Needed[m.Module]) { - own, family, ok := m.OwnSecrets.Lookup(name) - if !ok || family == "" || with.Needed[m.Module][name] == "" { - continue - } - // A per-secret owner (novox/hq ADR 0436 §1) stands in for the module's secrets-owner, when - // the manifest named one for this secret (or the family it belongs to). - owner := m.SecretsOwner - if own.Owner != "" { - owner = own.Owner - } - first = append(first, ownedBy(owner, map[string]any{ - "id": NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name], - })) - } - // **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is - // declared placed and without a value, filled by the machine's provisioner client from the vault; the - // controller makes and seals none of them here. - handsSecrets := with.Handing(m) - for _, name := range sortedKeys(m.OwnSecrets.Plain()) { - if handsSecrets { - owner := r.ownSecretOwner(m, name) - first = append(first, ownedBy(owner, map[string]any{ - "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name, - })) - continue - } - // **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this - // machine, which the controller cannot open; the controller's own sealed value is not sent with it. - if box, ok := with.vaultSealedOf(m.Module, name); ok { - if string(box) == string(ForeseenVaultBox) { - *foreseen = append(*foreseen, m.Module+"/"+name) - } - owner := r.ownSecretOwner(m, name) - first = append(first, ownedBy(owner, map[string]any{ - "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box, - })) - continue - } - sealed := with.Needed[m.Module][name] - if sealed == "" && with.Foreseen[m.Module][name] { - // Not made, and the next send makes it: composed with a stand-in so that whatever - // else this composition would refuse is still found (novox/hq issue 275). - sealed = ForeseenSealed - *foreseen = append(*foreseen, m.Module+"/"+name) - } - if sealed == "" && m.OwnSecrets[name].IssuedBy == IssuedOutside { - // **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a - // family's member (ADR 0283), so the module reads its absence as not given. - continue - } - if sealed == "" { - // Declared and not made. Refused rather than skipped: a module whose own - // credential is silently absent starts, fails to authenticate, and the reason is - // three layers away from the machine reporting it. - return nil, &NotMadeError{Module: m.Module, Name: name} - } - // The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175, - // to-be 38 WP3): its process is composed `user: ` where the node has one, and a - // root-owned 0600 file is one that process cannot read. Composed here rather than said in - // the manifest, because a manifest cannot say ${machine:account} safely — a node with no - // account has nothing to resolve it to, and then the runtime runs as root and the file - // stays root's. - first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{ - "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed, - })) - } - // This module's tools bundles, where the machine runs the node's tool runtime (novox/hq - // ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's - // own resources for the same reason: the runtime's process names the files inside these - // and is restarted when one changes, so they are on the machine before it is. - if r.runtimeHere() { - first = append(first, bundleArchives(m)...) - } - // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before - // the module's own resources, and so before the container that mounts them: the host must - // find each present — refusing clearly if the operator has not provided it — before it - // starts anything that depends on it. The mesh creates, chowns and reconciles none of it; - // an `access` resource says only *this path must exist, and this module reaches it*. - // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id - // where the operator said, the definition's default otherwise, refused where neither. - accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module]) - if err != nil { - return nil, err - } - for _, a := range accesses { - first = append(first, map[string]any{ - "id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode, - }) - } - for _, to := range m.SecretRequirements() { - for _, file := range m.SecretFiles(to) { - var found *Needed - for i, n := range r.Needs { - // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching - // on the name alone, every consumer of a provision took whichever credential - // happened to be last in the list — so on a node with two of them, one module - // would be given the other's password and fail to authenticate with a valid - // credential belonging to somebody else. And this file's local name, where the - // module keeps several (ADR 0094). - if n.Name == to && n.For == m.Module && n.Local == file.Local { - found = &r.Needs[i] - } - } - // **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its - // file is declared placed and without a value, and the machine's client fills it from the vault. - if found != nil && found.IssuedToHolder && with.Handing(m) { - first = append(first, ownedBy(m.SecretsOwner, map[string]any{ - "id": SecretID(SecretLocal(to, file.Local)), "type": "file", "path": file.Path, - HandedProvisionField: PlanProvision{Name: SecretLocal(to, file.Local), Provision: to, - Local: file.Local, Provider: found.From, ProviderModule: found.Module}, - })) - continue - } - if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { - // Answered by a record whose key has not been supplied since this consumer was - // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the - // key was accepted and cannot seal another, so a machine that resolved cleanly - // would receive no file at all and fail at whatever tried to read it — which is - // the outcome ADR 0024 exists to avoid, arrived at politely. - // - // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token - // is a licence whose manager has not adopted one yet, a real waiting state rather than - // a lost key. It falls through to the skip below — its bound facts (carrying the - // manager's public key) are still delivered, which is what adoption needs to seal the - // first refresh token. - return nil, fmt.Errorf( - "%s on this machine uses the licence %q and no key has been sealed to it. "+ - "The mesh cannot make one; supply it again with `licence key %s`", - m.Module, found.From, found.From) - } - if found == nil || found.Sealed == "" { - // Answered on this machine, or answered by a node the mesh could not seal to. - // Nothing to write either way, and writing an empty credential file would be - // worse than none: something would read it and fail authenticating. - continue - } - first = append(first, ownedBy(m.SecretsOwner, map[string]any{ - "id": SecretID(SecretLocal(to, file.Local)), "type": "file", "path": file.Path, - "sealed": found.Sealed, - })) - } - } - for _, to := range sortedKeys(m.Grants) { - for _, g := range with.Grants { - if g.Provision != to { - continue - } - if g.From == "" { - // Nothing on that machine asks for this any more. Skipped here rather than - // where grants are gathered, so the rule holds whoever gathers them. - // - // **This is how a credential is withdrawn.** 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. - continue - } - if g.Sealed == "" { - // Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential - // file would be read as one that failed. Who asked still reaches the provider in its contributions. - continue - } - first = append(first, ownedBy(r.provisionsAs(m), map[string]any{ - // One file per holder — the consumer's module with its local name after it - // where it keeps several (ADR 0094); the lab found two files with one id. - "id": GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)), - "type": "file", - "path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)), - "sealed": g.Sealed, - })) - } - } - for _, to := range sortedKeys(m.Binds) { - var found *Needed - for i, n := range r.Needs { - if n.Name == to && n.For == m.Module { - found = &r.Needs[i] - } - } - if found == nil { - // Answered on this same machine rather than from elsewhere in the mesh. - // - // **Still written.** It used to be skipped, reasoning that "it is on this node" - // is a fact nobody needs — and that is true of the *location* and false of - // everything beside it. A binding also carries what the provider said a consumer - // must know, which is the port; a consumer cannot invent that, and got an absent - // file with no explanation. A build machine sharing a node with the registry it - // pushes to sat in a loop saying it could not read its own binding. - here, err := here(r, to, with) - if err != nil { - return nil, err - } - if here == nil { - // Nothing in this node's set offers it either, so there is genuinely nothing - // to say. Resolution has already refused anything unanswerable, so this is a - // requirement met by the module itself. - continue - } - found = here - } - own, err := r.ownNames(m, to, with.Settings[m.Module]) - if err != nil { - return nil, err - } - as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)) - // What the provider derives for THIS consumer, filled here where the consumer is - // known (novox/hq ADR 0202). The same fill knownFor does below, so the binding file - // and the module's `${bound:…}` substitutions cannot say different things. - told := *found - told.Serves, err = ServedTo(told.Serves, as) - if err != nil { - return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err) - } - file, err := boundFile(told, m.Binds[to], as, own) - if err != nil { - return nil, err - } - first = append(first, file) - } - for _, to := range sortedKeys(m.Receives) { - file, err := receivedFile(to, m.Receives[to], given[to]) - if err != nil { - return nil, err - } - first = append(first, file) - if received[m.Module] == nil { - received[m.Module] = map[string][]Contribution{} - } - // Empty rather than absent when nobody contributed, for the reason the file is - // written empty: "nothing asked" and "never told" want different responses. - received[m.Module][to] = append([]Contribution{}, given[to]...) - } - if m.Keeps != "" && with.Kept != nil { - file, err := keptFile(m.Keeps, with.Kept) - if err != nil { - return nil, err - } - // Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723). - first = append(first, ownedBy(m.SecretsOwner, file)) - } else if runs, ok := with.KeptRuns[m.Module]; m.Keeps != "" && ok { - file := make(map[string]any, len(runs)) - for k, v := range runs { - file[k] = v - } - file["id"] = KeptID() - delete(file, "module") - first = append(first, file) - } - if m.Computed != "" { - mine, part := generated[m.Module] - if !part { - // Assigned, and not yet part of what this generates. Nothing to put on the - // machine, which is different from an error: a node given the network module - // before it has an address is in exactly that state, briefly. - continue - } - resources = mine - } - - // Now, and not before: a module whose resources are computed replaces them wholesale, and - // merging earlier would throw away the files it still needs. - // - // **Except the module's own accounts, which go before even those** (novox/hq issue 213). What - // the mesh computes may belong to one: a module whose code runs as an account it declares has - // its secrets written owned by that account, and a file given to a user the machine does not - // have yet fails — so on the first apply the secrets were refused, the process started without - // them, and the second apply healed it, which is the fault the paragraph above describes. - // An account depends on nothing the mesh computes. - accounts, rest := accountsFirst(resources) - resources = append(append(accounts, first...), rest...) - // **A file delivered only while its optional requirement is bound is left out while it is - // absent** (novox/hq ADR 0330 §5), and the field is dropped from the rest: the node-engine - // parses strictly and knows no such field. - absent := r.AbsentFor(m.Module) - resources = withoutAbsent(resources, absent) - - // No container is given the mesh's names (novox/hq ADR 0148). It used to be: every - // container got the whole roster as `--add-host` entries at creation, and a name that - // moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once - // the roster was made part of a container's identity so that could be caught, one name - // moving anywhere replaced every container in the mesh (issue 151). A container resolves a - // mesh name through its machine's resolver at the moment it asks, which the runtime is - // told once per machine, as a file, by the resolver's own module. The names a module - // declares for itself are its own and stay exactly as written: they are part of what the - // module is, and the mesh does not know what they mean. - // What this module may name from inside one of its own files. Gathered once per module - // rather than per file, because it is a fact about the module. - sealed, err := sealedFor(m, r.Needs, with) - if err != nil { - return nil, err - } - // And what its bindings say, for the half of a connection that is not secret. - known, err := knownFor(m, r.Needs, r.Node) - if err != nil { - return nil, err - } - // A requirement answered on this same machine is not in r.Needs — its binding file is - // written from `here` (above) — and so `${bound:…}` could not name it, though the file - // beside it said the same facts. Filled from the same answer, so the two cannot disagree. - for _, want := range m.Wants() { - if _, has := known[want]; has { - continue - } - answered, err := here(r, want, with) - if err != nil { - return nil, err - } - if answered == nil { - continue - } - local := *answered - local.For = m.Module - here, err := knownFor(m, []Needed{local}, r.Node) - if err != nil { - return nil, err - } - for provision, values := range here { - known[provision] = values - } - } - // And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is - // what nearly every consumer wants; the one that cannot use it is a machine's resolver - // configuration, which must reach the resolver before it can resolve anything — the resolver's - // own name included. Absent when the machine has no address yet, so a file naming it is refused - // rather than written with a blank where an address belongs. - for _, values := range known { - if address := with.Machines[values["at"]]; address != "" { - values["address"] = address - } - } - // And what the module is called through each requirement it contributes to (novox/hq - // 04-ISSUES/122) — the same composition its binding file carries. - for provision, values := range known { - own, err := r.ownNames(m, provision, with.Settings[m.Module]) - if err != nil { - return nil, err - } - withOwnNames(values, own) - } - // And where this node places the directories the module declared without a path - // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. - // — and, on an adopted machine, where the assignment says they already are, with the - // owner the data already has (novox/hq issue 153). Malformed placements are refused here. - placed, err := Places(m, with.Settings[m.Module]) - if err != nil { - return nil, err - } - dirs := dirsFor(m, with) - // And the machine underneath, which no binding of its own can tell it. - thisMachine := machineFacts(r, with.Names, with.MeshRange) - - // **A definition that already holds the answer transcribed it** (novox/hq ADR 0202). - // Judged over what the module itself declares, and before anything is substituted: the - // mesh's own generated files — the binding, the contributions — legitimately carry the - // derived value, and after substitution so does every consumer's file, so this is the one - // moment the two can be told apart. - for _, own := range m.Resources { - if err := notTranscribed(own, known, m.Module); err != nil { - return nil, err - } - } - // **And every placeholder no pass fills where it stands is refused** (novox/hq issue 231): - // judged here, over the definition as written and before any pass, by the function the - // catalogue check runs — so a manifest registered by an older binary is judged too, and - // what a setting or a binding later puts into a file is its software's text, never swept. - for _, own := range m.Resources { - if problems := unconsumedPlaceholders(m.Module, own); len(problems) > 0 { - return nil, fmt.Errorf("%s", problems[0]) - } - } - - // Which of this module's files carry a secret, for the rule that a container may not read - // one of them as its environment without saying so (ADR 0086, issue 041). - secretFiles := secretFilesOf(resources) - - // Which of this module's resources its preparation runs before, if it prepares anything. - prepareBefore := preparationTarget(m) - - // Which found networks this machine's setting keeps for each of its containers (novox/hq - // ADR 0163, rule 4); judged above, so an invalid one is not here. - keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted) - - // **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a - // machine that can take them, each is declared placed and without its text, and no pass below fills it. - handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs, - backupPlacer{append(append([]Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities, - unplaced}) - if err != nil { - return nil, err - } - // **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose - // provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own, - // so the seat keys it and the helper hears it. - keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs) - if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) { - for _, to := range requirementsContributedTo(m) { - own, err := r.ownNames(m, to, with.Settings[m.Module]) - if err != nil { - return nil, err - } - handed.name(m.Module, to, own) - contributed, err := r.contributedOf(m, to, with) - if err != nil { - return nil, err - } - handed.contribute(m.Module, to, contributed) - } - } - // A handed collector is filled from items alone: each old form its collections would have held is said - // (design 57 L1). - if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 { - *unplaced = append(*unplaced, oldFormsLeftOut(append(append([]Manifest(nil), r.Modules...), - stillBackedUp...), m, plan.Collections)...) - } - for _, unsettled := range resources { - if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok { - if handing == nil && m.Stubbed { - continue // held: its credential file is what runs of it, left out (issue 1126) - } - if handing == nil { - return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+ - "not: nothing would fill it", m.Module, pr.Provision) - } - marked := map[string]any{} - for k, v := range unsettled { - marked[k] = v - } - delete(marked, HandedProvisionField) - marked[HandedSecretField] = pr.Name - file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed) - if err != nil { - return nil, err - } - file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) - owner[fmt.Sprint(file["id"])] = m.Module - pr.Path = sec.Path - if err := handed.addProvisionFile(m.Module, pr); err != nil { - return nil, err - } - out = append(out, file) - continue - } - if unsettled[HandedSecretField] != nil { - if handing == nil && m.Stubbed { - continue // held: its own secret's file is what runs of it, left out (issue 1126) - } - if handing == nil { - return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+ - "not: nothing would fill it", m.Module, unsettled[HandedSecretField]) - } - file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed) - if err != nil { - return nil, err - } - file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) - owner[fmt.Sprint(file["id"])] = m.Module - if err := handed.addSecretFile(m.Module, sec); err != nil { - return nil, err - } - out = append(out, file) - continue - } - if handing != nil && fmt.Sprint(unsettled["type"]) == "file" { - if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 && - !handing[fmt.Sprint(unsettled["id"])] { - return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+ - "secrets reach only the files its provisioner client fills: keep it in a template "+ - "(design 57 V4)", m.Module, unsettled["id"]) - } - } - if handing[fmt.Sprint(unsettled["id"])] { - file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with) - if err != nil { - return nil, err - } - file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) - owner[fmt.Sprint(file["id"])] = m.Module - if err := handed.add(m.Module, line); err != nil { - return nil, err - } - out = append(out, file) - continue - } - if fmt.Sprint(unsettled["type"]) == "file" { - if _, left := consumersHandedOnly(m, unsettled, with); left { - *handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled)) - continue - } - } - resource, err := ApplySettings(unsettled, with.Settings[m.Module]) - if err != nil { - return nil, err - } - copied := map[string]any{} - for k, v := range resource { - copied[k] = v - } - if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps { - // The container also joins the found network the setting names, so a neighbour - // that resolves it there keeps resolving it. Passed to the host as its own field, - // which it joins after the container is made. - joins := make([]any, 0, len(networks)) - for _, n := range networks { - joins = append(joins, n) - } - copied["networks"] = joins - } - if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil { - return nil, err - } - // Said in the catalogue, not on the machine: the host parses strictly and knows no - // such field, and the reason is for a reader of the manifest. - delete(copied, SecretsInEnvironment) - delete(copied, NamesOnPurpose) - delete(copied, TrustedField) - // A process's bus stays controller-side (novox/hq ADR 0436 §1): never composed onto the - // node-engine's process resource, whose wire declaration is mesh-host's own, vendored, with a - // strict decoder that refuses an unknown key. - delete(copied, ProcessBusField) - // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a - // definition may not carry because it is true of one installation only. Filled from - // the same layers a mergeable file takes, and refused when no layer set it. - if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); err != nil { - return nil, err - } - // **Placed before anything reads a path.** A pathless directory receives the path - // this node resolves for it, and every ${dir:…} — in paths, mounts, content and - // environment — becomes that path, so what follows sees only concrete places - // (novox/hq ADR 0112). The host receives paths exactly as it always has. - if err := dirInto(copied, dirs, m.Module); err != nil { - return nil, err - } - // The operator's data the same way: ${access:…} becomes where this node keeps it, - // and a placed directory takes the owner the assignment said (issue 153). - if err := accessInto(copied, accessPaths, m.Module); err != nil { - return nil, err - } - ownerInto(copied, placed) - // **After settings, and that is the whole reason it is here.** A module's file - // content is where a setting lands, so a placeholder may only exist once the setting - // has been put in — filling secrets first would look at content that is not yet what - // the machine receives. - if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil { - return nil, err - } - // **Substituted here, not on the machine.** A bound value is not secret — the mesh - // holds it in the clear — so there is nothing for the host to be the only witness of, - // and sending it already filled in means the host learns no new field. - if err := boundInto(copied, known, m.Module, absent); err != nil { - return nil, err - } - // And what the module could not have written: the machine it turned out to be - // assigned to. Beside the bound values because it is the same kind of fact — the - // mesh's own, held in the clear — and because a module that must name itself to - // something else has no binding to learn it from (novox/hq ADR 0066). - // Which port this machine gave it, for a module that binds directly rather than - // through a runtime that can remap (ADR 0038). Applied before the machine's facts so - // a refusal names the port rather than whatever came after it. - if err := portInto(copied, m.Module, m.Listens, with); err != nil { - return nil, err - } - // And where this machine put the foundation's servers, for the one module that - // reaches them by seat rather than by binding (novox/hq 04-ISSUES/102). - if err := seatInto(copied, m.Module, with); err != nil { - return nil, err - } - if err := machineInto(copied, thisMachine, m.Module); err != nil { - return nil, err - } - if err := pinned(copied, m.Module); err != nil { - return nil, err - } - // And the other half of the same question: an image nobody has published yet is named - // by an artifact rather than by a placeholder digest, and is just as unrunnable. - if err := built(copied, m.Module); err != nil { - return nil, err - } - // What the mesh built is kept by digest and path; the store's address is this - // network's now, composed here and never recorded (novox/hq 04-ISSUES/102). - if err := artifactsInto(copied, m.Module, with); err != nil { - return nil, err - } - publishedOn(copied, m.Module, with) - // How it is ready, in the node-engine's words: its endpoint as the port this machine - // published it on — or not sent at all to an engine older than the field (ADR 0240). - healthInto(copied, m, with) - // And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it. - busyInto(copied, with) - // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine - // that judges it. - rootInto(copied, with) - // The account's environment and every module's shell code, where this module holds the - // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on - // the node, as the jails are, and **last of every placeholder pass**: shell code is a - // shell's own syntax, full of `${…}` no pass above should ever be shown. - contributing := append(append([]Manifest(nil), r.Modules...), stillBackedUp...) - if err := contributionsInto(copied, m, contributing, thisMachine, with, r.Capabilities, unplaced); err != nil { - return nil, err - } - copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) - // A service saying what it reflects names resources within its own module, so those - // are prefixed too or they would point at nothing. - // - // **Unless it already names one.** A module may reflect a file another module put on - // the machine — the case this exists for is a resolver restarting when the mesh - // rewrites the names, which are computed by the mesh and belong to its module, not to - // the daemon's. Written `.`, and a dot is what marks it as already - // answered: prefixing it again would point at nothing, silently, and the daemon would - // serve the old names for ever while everything reported success. - if renamed := reflectsRenamed(m.Module, resource["restart-on"]); renamed != nil { - copied["restart-on"] = renamed - } - // And what it is reloaded on, by the same rule (novox/hq ADR 0102): an id left - // unprefixed matches nothing, and the service is never reloaded. - if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { - copied["reload-on"] = renamed - } - // And which of its module's containers a scheduled step holds still (novox/hq ADR 0189). - // **The loudest of the three when it is missed.** An unprefixed `restart-on` matches - // nothing and a service quietly never restarts; an unprefixed `while-stopped` names a - // container the declaration does not contain, and the host refuses the whole - // declaration — so the machine takes nothing at all, for every push, until this is - // right. That is what it did on the control node (2026-10-04). - if renamed := reflectsRenamed(m.Module, resource[WhileStopped]); renamed != nil { - copied[WhileStopped] = renamed - } - // And what a process replaces (novox/hq issue 213): a resource of this module's that it - // no longer declares, named as the host recorded it, or the host hands nothing over and - // removes it first. - if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil { - copied["replaces"] = renamed - } - // **What reads one of this module's own secrets is restarted when it changes** (novox/hq - // issue 203, issue 206). A credential is re-issued by the mesh, and a container that - // mounted the old file keeps the old one open: the build machine ran for an hour on a - // credential the mesh had replaced, because its manifest restarted it on its - // environment file and nobody had thought to name the credential too. Composed here so - // no manifest has to say it, for a container or a daemon that names the secret's path. - if reads := secretsReadBy(copied, m, with.Needed[m.Module]); len(reads) > 0 { - copied["restart-on"] = withRestartOn(copied["restart-on"], reads) - } - // **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the - // module's own resource rather than declared beside it: what prepares the state is the - // module's own code, so what it is given has to be what that code is given — and a - // second resource written by hand is a second copy to drift from the first. Placed - // immediately before it, because a run-once step stops everything the declaration - // places after it (ADR 0052), which is how a version whose preparation failed does not - // serve. - if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore { - step := prepared(copied) - owner[fmt.Sprint(step["id"])] = m.Module - out = append(out, step) - } - owner[fmt.Sprint(copied["id"])] = m.Module - out = append(out, copied) - } - - // **What only the mesh knows, where this module asked for it.** The graph is the control - // plane's; making a name resolve is the module's software. Emitted as ordinary files under - // this module's name, so they are applied, reported and removed exactly as anything else - // it declares. - // The resolver file is the node-resolver holder's where one is held here, and the uplink's - // holder steps back from it (novox/hq ADR 0247). - given, err := FactsFrom(stepsBack(m, r.Modules), r, with) - if err != nil { - return nil, err - } - for _, fact := range given { - fact["id"] = m.Module + "." + fmt.Sprint(fact["id"]) - owner[fmt.Sprint(fact["id"])] = m.Module - out = append(out, fact) - } - } - // The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every - // bundle delivered above and holding the credential sealed above, so both exist before it starts - // — the order written here is the order the machine applies. - if r.runtimeHere() { - process, err := r.runtimeProcess(with) - if err != nil { - return nil, err - } - if process != nil { // none for a held runtime's stand-in: what runs of it is carried - owner[fmt.Sprint(process["id"])] = RuntimeModule - out = append(out, process) - } - // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). - owns, err := r.ownRuntimes(with) - if err != nil { - return nil, err - } - for _, p := range owns { - id := fmt.Sprint(p["id"]) - owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID()) - out = append(out, p) - } - // What each module's bundles are given is read as the account the runtime runs as — not what a - // module of its own account is given, which its own account reads. - words := map[string]map[string]string{} - for _, m := range r.Modules { - if m.RunsAs != "" { - continue - } - w, err := bundleWords(m, with) - if err != nil { - return nil, err - } - words[m.Module] = w - } - // And a file a module's words name is one the runtime is restarted for when it changes. - if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil { - restarts, _ := process["restart-on"].([]any) - seen := map[string]bool{} - for _, id := range restarts { - seen[fmt.Sprint(id)] = true - } - for _, id := range named { - if !seen[id] { - restarts = append(restarts, id) - seen[id] = true - } - } - process["restart-on"] = restarts - } - // **And what a module of its own account is given, its own account reads** (issue 723): the files and - // directories its words name that say no owner, given to the account it runs as, never the operator's. - for _, m := range r.Modules { - if m.RunsAs == "" { - continue - } - w, err := bundleWords(m, with) - if err != nil { - return nil, err - } - givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs) - } - } - if with.Adopted { - // First, before anything a module declares: what the mesh needs reachable, then its guard. - // The order a machine applies is the order written here. - ours := Openings(rules, with.Foundation, Published(out)) - ours = append(ours, GuardResources(r.guarded(out, owner, rules, with))...) - out = append(ours, out...) - } - return out, nil -} - -// guarded is what the mesh's guard refuses on an adopted node (novox/hq ADR 0103): derived, and -// for taken modules only. -// -// For each taken module, every machine port its containers publish that the filter would admit -// from the private network only — a published port is forwarded, not received, so a found -// firewall filtering only what it receives never sees it — together with the ports the module's -// manifest guards explicitly (the store's port, the broker's management port), wherever the -// machine put them. A port of a module assigned but not taken is not guarded: it may still be the -// predecessor's, serving the predecessor's other machines. The foundation's ports are admitted -// from everywhere and are never guarded. -func (r Resolution) guarded(out []map[string]any, owner map[string]string, rules []Rule, - with Rendering) []int { - meshOnly := map[int]bool{} - fromEverywhere := map[int]bool{} - for _, rule := range rules { - if rule.Protocol != "tcp" { - continue - } - switch rule.From { - case FromMesh: - meshOnly[rule.Port] = true - case FromEverywhere: - fromEverywhere[rule.Port] = true - } - } - for _, port := range with.Foundation { - delete(meshOnly, port) - fromEverywhere[port] = true - } - seen := map[int]bool{} - var ports []int - guard := func(at int) { - if !seen[at] { - seen[at] = true - ports = append(ports, at) - } - } - for _, m := range r.Modules { - if !with.Taken[m.Module] { - continue - } - var mine []map[string]any - for _, resource := range out { - if owner[fmt.Sprint(resource["id"])] == m.Module { - mine = append(mine, resource) - } - } - for outer := range Published(mine)["tcp"] { - if meshOnly[outer] { - guard(outer) - } - } - for _, want := range m.Guards { - at := with.machinePort(m.Module, want) - for _, resource := range mine { - if fmt.Sprint(resource["type"]) != "container" { - continue - } - listed, _ := resource["ports"].([]any) - for _, entry := range listed { - outer, inner, _, ok := mapping(fmt.Sprint(entry)) - if ok && inner == want { - at = outer - } - } - } - // Not what this node is told to open to everyone: a per-node exposure setting that - // widens a guarded port is the operator saying so, and declaring an opening for it - // and a guard dropping it would be one statement refusing the other. - if !fromEverywhere[at] { - guard(at) - } - } - } - sort.Ints(ports) - return ports -} - // mapping reads a container's port mapping — `[address:]outer:inner[/protocol]`, the address // possibly an IPv6 one in brackets — indexing from the end, so an address's own colons never // shift the ports. Not ok for a short form or anything that is not a mapping. @@ -1528,70 +348,6 @@ func mapping(written string) (outer, inner int, address string, ok bool) { return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true } -// filtersHere is the module on this node that loads the machine's packet filter, or empty when none -// does. Named rather than counted: a refusal that says which module is one step from acted on. -func (r Resolution) filtersHere() string { - for _, m := range r.Modules { - if m.Filtering != nil { - return m.Module - } - } - return "" -} - -// Rules is the rule set this node's filter is derived from: every module's listens, what was -// computed for this machine, and each module's per-node exposure. The same answer whether the node -// is adopted or converged — the one loads it as a filter, the other declares it as openings. -func (r Resolution) Rules(with Rendering) ([]Rule, error) { - // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163, - // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let - // through. - left := r.LeftOut(with.Settings, with.Adopted) - exposure := map[string]map[int]string{} - for _, m := range r.Modules { - if _, isLeft := left[m.Module]; isLeft { - continue - } - e, err := Exposure(m, with.Settings[m.Module]) - if err != nil { - return nil, err - } - // And how far each endpoint reaches, which says the same thing to the filter and more - // besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one - // question — from where — and a reach answers it, so giving it two inputs would let them - // disagree. Reaches refuses a port that both name, so this cannot silently prefer one. - reaches, err := Reaches(m, with.Settings[m.Module]) - if err != nil { - return nil, err - } - // **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the - // proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and - // says nothing about the port — opening it to the world as well would undo the arrangement - // the proxy exists for, and would silently reopen a port an operator had narrowed. - // - // Found by trying to express a real module: one whose routed name must be public and whose - // machine-side port must not be. Under one value for both, there was no way to say it. - routed := RoutedPorts(m) - for port, reach := range reaches { - if routed[port] { - continue - } - source, ok := FilterSource(reach) - if !ok { - return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach) - } - if e == nil { - e = map[int]string{} - } - e[port] = source - } - if e != nil { - exposure[m.Module] = e - } - } - return r.Filtering(with.Generators, with.Ports, exposure) -} - // Contribution is one module telling the answer to a requirement what it needs from it. type Contribution struct { // From is the module that said it, so the provider and a person reading the file can tell @@ -1647,492 +403,6 @@ type Contribution struct { Derived map[string]any `json:"derived,omitempty"` } -// provisionsAs is the account that reads what the mesh writes for this provider: the one secret -// per consumer it must open to set that consumer's password (novox/hq issue 225). -// -// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already -// written above for a module's own secrets — and the grant secret is the other kind of secret -// the mesh writes for a module, so it is the same rule. -// -// Which account depends on where the module's code runs. A module whose code is a bundle is run -// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container -// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that -// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the -// harness composes a grant secret's path from the contributions file, not from a word. -// -// Empty is root, which is what it was and what a module with no bundle and no declared owner -// still wants. -func (r Resolution) provisionsAs(m Manifest) string { - // A module of its own account reads them as that account (ADR 0259 §8, issue 723). - if m.RunsAs != "" { - return m.RunsAs - } - if len(m.Bundles) > 0 && r.Account != "" { - return r.Account - } - return m.SecretsOwner -} - -// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the -// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR -// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none). -func (r Resolution) ownSecretOwner(m Manifest, name string) string { - if o := m.OwnSecrets[name].Owner; o != "" { - return o - } - if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) { - return r.Account - } - return m.SecretsOwner -} - -// grantPath is where one consumer's sealed credential lands on the providing machine. -// -// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a -// node named like something else in that directory cannot collide with it. -// One file per consumer, and a consumer is a module on a machine (novox/hq 04-ISSUES/022). -// -// Named after both. Named after the machine alone, two modules on one node wrote to one path: the -// second overwrote the first, and the provisioner — reading a directory — saw one consumer where -// there were two. -// reflectsRenamed is a resource's restart-on list under the module's prefix, or nil when it has -// none. It reads both the shape JSON parsing produces ([]any) and the shape code composing -// resources natively produces ([]string): a reference that was skipped because its list arrived -// in the other shape would point at nothing — silently, with the service never restarting and -// every check passing, which is how a runtime kept serving without the registry trust its -// daemon file already carried. -func reflectsRenamed(module string, reflects any) []any { - var names []string - switch v := reflects.(type) { - case []any: - for _, id := range v { - names = append(names, fmt.Sprint(id)) - } - case []string: - names = v - } - if names == nil { - return nil - } - var renamed []any - for _, named := range names { - if strings.Contains(named, ".") { - renamed = append(renamed, named) - continue - } - renamed = append(renamed, module+"."+named) - } - return renamed -} - -func grantPath(directory, consumer, module string) string { - return strings.TrimRight(directory, "/") + "/" + consumer + "." + module + ".secret" -} - -// holderAs is a consumer's name at the provider with a local name after it, where it keeps several -// credentials for one provision (ADR 0094); the name alone otherwise. -func holderAs(as, local string) string { - if local == "" { - return as - } - return as + "_" + local -} - -// contributions collects what every module in this set contributes, by requirement. -// -// Ordered by contributing module, because the result becomes a file on a machine and a file whose -// lines move about is a file that looks changed when nothing changed. -func (r Resolution) contributions(settings SettingsBy, grants []Grant, - directories map[string]string) (map[string][]Contribution, error) { - out := map[string][]Contribution{} - modules := append([]Manifest{}, r.Modules...) - sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module }) - - // What consumers on other machines asked for. 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 be a provider that reads one of them. - sorted := append([]Grant{}, grants...) - sort.Slice(sorted, func(i, j int) bool { - if sorted[i].Provision != sorted[j].Provision { - return sorted[i].Provision < sorted[j].Provision - } - if sorted[i].Consumer != sorted[j].Consumer { - return sorted[i].Consumer < sorted[j].Consumer - } - if sorted[i].From != sorted[j].From { - return sorted[i].From < sorted[j].From - } - return sorted[i].Local < sorted[j].Local - }) - // A consumer already carried by the grants loop, keyed (provision, module). When provider and - // consumer are co-located, `grantsFor` enumerates the same-node consumer too, so without this the - // module would be emitted a second time by the m.Contributes loop below — once full (with the - // grant's secret/as) and once partial — which is the duplicate seen in a co-located mesh.json. - granted := map[string]map[string]bool{} - for _, g := range sorted { - if g.From == "" { - // As above: nothing on that machine asks for this any more, so the provider is not - // told about it and withdraws the login on its next pass. - continue - } - as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local) - derived, err := r.derivedFor(g.Provision, as, g.From, g.Local, settings) - if err != nil { - return nil, err - } - out[g.Provision] = append(out[g.Provision], Contribution{ - From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived, - // One holder per local name: the identity the consumer is known by, and the local name - // after it where the module keeps several (ADR 0094). Not a login any backend checks — - // a secret is not a login — so the identity limit does not apply to the suffix. - As: as, - Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), - }) - if granted[g.Provision] == nil { - granted[g.Provision] = map[string]bool{} - } - granted[g.Provision][g.From] = true - } - for _, m := range modules { - for _, to := range sortedKeys(m.Contributes) { - // The grants loop already emitted this module's contribution to this provision, with its - // minted secret — emitting the partial copy again would duplicate it. A contribution with - // no grant (a reverse-proxy route names a host, not a credential) is not in `granted`, so - // it still reaches the provider from here. - if granted[to][m.Module] { - continue - } - // Settings reach a contribution the same way they reach a file. A route's hostname is - // exactly the kind of thing that differs between one mesh and the next, and a module - // that could not have it set would have to be edited to be reused. - values, err := r.composed(m, to, m.Contributes[to], settings[m.Module], - m.Module+" contributing to "+to) - if err != nil { - return nil, err - } - // What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8). - if values, err = withShares(m, to, values); err != nil { - return nil, err - } - out[to] = append(out[to], Contribution{From: m.Module, Values: values}) - } - // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an - // object store's data API and its console are two different public names from one module, - // not one. Never in `granted` — a route names a host, not a credential — so every local - // name always reaches the provider from here. - for _, to := range sortedKeys(m.ContributesMany) { - for _, local := range sortedKeys(m.ContributesMany[to]) { - values, err := r.composed(m, to, m.ContributesMany[to][local], settings[m.Module], - m.Module+" contributing "+local+" to "+to) - if err != nil { - return nil, err - } - out[to] = append(out[to], Contribution{From: m.Module, Values: values}) - } - } - } - return out, nil -} - -// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received -// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by -// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a -// plan and a file cannot say different things. Nil where m contributes nothing to it. -// -// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs, -// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan -// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000 -// on another machine (published at 20012) until it did (2026-10-31). -func (r Resolution) contributedOf(m Manifest, to string, with Rendering) (json.RawMessage, error) { - layers := with.Settings[m.Module] - form := ContributedForm{At: r.At} - add := func(local string, values map[string]any) error { - values, err := r.atAssignedPort(m, to, values, with) - if err != nil { - return err - } - raw, err := json.Marshal(values) - if err != nil { - return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) - } - form.Each = append(form.Each, ContributedOne{Local: local, Values: raw}) - return nil - } - if raw, ok := m.Contributes[to]; ok { - values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to) - if err != nil { - return nil, err - } - if values, err = withShares(m, to, values); err != nil { - return nil, err - } - if err := add("", values); err != nil { - return nil, err - } - } - for _, local := range sortedKeys(m.ContributesMany[to]) { - values, err := r.composed(m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to) - if err != nil { - return nil, err - } - if err := add(local, values); err != nil { - return nil, err - } - } - if len(form.Each) == 0 { - return nil, nil - } - return EncodeContributed(form) -} - -// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number -// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's -// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed -// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address. -// -// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the -// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition -// with no ports) such a port is left as it is, as machinePort leaves it. -func (r Resolution) atAssignedPort(m Manifest, to string, values map[string]any, with Rendering) (map[string]any, error) { - number, ok := asPort(values["port"]) - if !ok { - return values, nil - } - _, given := with.Given[m.Module][number] - _, placed := with.Ports[m.Module][number] - if !given && !placed { - if with.Planned { - return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+ - "machine port stands for it", m.Module, to, number, r.Node) - } - return values, nil - } - return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: with.machinePort(m.Module, number)}}), nil -} - -// composed is one contribution as its provider receives it: settled with this node's settings, its -// endpoint's port filled in, and its names composed from the label. -// -// **One function, because two readers must agree.** The provider is told the names in its received -// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122). -// Composing them twice, in two places, is how the proxy would come to serve one name while the -// module wrote another into its configuration. -func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) ( - map[string]any, error) { - // Overridden, not merged: a setting changes a key the contribution declares and adds none. - // The provider reads the contribution as a contract, and a setting made for one of this - // module's files is no part of it (novox/hq 04-ISSUES/173). - values, err := overridden(raw, WithDefaults(m, layers), what) - if err != nil { - // A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's - // are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`). - var unset *UnsetSettingError - if errors.As(err, &unset) && unset.Module == "" { - unset.Module = m.Module - unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field) - } - return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m) - } - reaches, err := Reaches(m, layers) - if err != nil { - return nil, fmt.Errorf("%s: %w", what, err) - } - blocks, err := Endpoints(m, layers) - if err != nil { - return nil, fmt.Errorf("%s: %w", what, err) - } - portOfEndpoint(values, endpointPorts(m)) - composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks) - return values, nil -} - -// ownNames is what a module is known by through what it contributes to one requirement — the names -// the mesh composed for it, and nothing else of the contribution. -// -// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the -// mesh joins it with this node's domains, and the provider serves the result — and the module itself -// was never told. Software that must know its own address (a login redirect, a canonical URL, an -// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong -// on every other machine. `${bound::name}` is the answer, from the same composition the -// provider receives. -// -// Several contributions to one requirement are keyed by their local name under `names`. -func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]any, error) { - pick := func(values map[string]any) map[string]any { - names := map[string]any{} - for _, key := range []string{"name", "internal-name"} { - if v, ok := values[key].(string); ok && v != "" { - names[key] = v - } - } - return names - } - out := map[string]any{} - if raw, ok := m.Contributes[to]; ok { - values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to) - if err != nil { - return nil, err - } - for k, v := range pick(values) { - out[k] = v - } - } - if locals := m.ContributesMany[to]; len(locals) > 0 { - many := map[string]any{} - for _, local := range sortedKeys(locals) { - values, err := r.composed(m, to, locals[local], layers, - m.Module+" contributing "+local+" to "+to) - if err != nil { - return nil, err - } - if names := pick(values); len(names) > 0 { - many[local] = names - } - } - if len(many) > 0 { - out["names"] = many - } - } - return out, nil -} - -// composeName joins a contribution's label with a node's public domain, and separately with its -// private one, in place (novox/hq ADR 0056). -// -// **The whole of what the mesh does with a route's name: join two given strings — twice.** A -// contribution carries a `label` — the subdomain its operator chose — and the node carries its -// public domain and its own private-network address; the granted names are `

"`, default files/; issue 731): a // module whose files/ is already an archive unpacked onto the machine keeps its templates in a directory of their // own, packed into an archive no resource names, so its unpacked files/ is exactly what it was. -// The two forms of one module whose files/ is unpacked onto the machine: its text inline, and in templates/. -const ( - unpackingInline = `{"module":"tidy","version":"1", - "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}]}, - "resources":[ - {"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"}, - {"id":"conf","type":"directory","path":"${dir:conf}"}, - {"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","content":"` + mainText + `"}, - {"id":"extra","type":"file","path":"/etc/extra.conf","content":"` + extraText + `"}]}` - unpackingTemplated = `{"module":"tidy","version":"1", - "templates":"templates", - "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}, - {"name":"templates","kind":"archive","from":"templates"}]}, - "resources":[ - {"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"}, - {"id":"conf","type":"directory","path":"${dir:conf}"}, - {"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","template":"templates/tidy.conf"}, - {"id":"extra","type":"file","path":"/etc/extra.conf","template":"templates/deeper/extra.conf"}]}` -) - func builtUnpacking(templates map[string]string) []Built { built := []Built{{Name: "scripts", Kind: ArtifactArchive, Reference: "https://store.invalid/scripts.tar.gz", @@ -43,77 +19,3 @@ func builtUnpacking(templates map[string]string) []Built { } return built } - -func TestAModuleWithItsOwnTemplatesDirectoryComposesByteIdenticallyToItsInlineTwin(t *testing.T) { - inline := composed(t, held(t, unpackingInline, builtUnpacking(nil))) - templated := composed(t, held(t, unpackingTemplated, builtUnpacking(templateTexts(t)))) - if inline != templated { - t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated) - } - // The unpacked archive is the files/ one, by its digest; the templates archive reaches no machine. - if !strings.Contains(templated, strings.Repeat("a", 64)) || strings.Contains(templated, strings.Repeat("b", 64)) { - t.Fatalf("the composition does not unpack files/ alone: %s", templated) - } -} - -func TestTheManifestTheMeshHoldsNamesNoTemplatesDirectory(t *testing.T) { - m := held(t, unpackingTemplated, builtUnpacking(templateTexts(t))) - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), `"templates"`) { - t.Fatalf("the held manifest still names its templates directory: %s", raw) - } -} - -func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) { - dir := t.TempDir() - for name, text := range templateTexts(t) { - full := filepath.Join(dir, "templates", filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte(text), 0o644); err != nil { - t.Fatal(err) - } - } - // files/ holds what the machine unpacks, a symlink among it judged by nobody here: it is not the templates. - if err := os.MkdirAll(filepath.Join(dir, "files", "bin"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "files", "bin", "tidy"), []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatal(err) - } - m, err := decodeForTest([]byte(unpackingTemplated)) - if err != nil { - t.Fatal(err) - } - filled, err := WithTemplatesFrom(m, dir) - if err != nil { - t.Fatal(err) - } - resolved, err := filled.Resolve(builtUnpacking(map[string]string{})) - if err != nil { - t.Fatal(err) - } - if got, want := composed(t, resolved), composed(t, held(t, unpackingInline, builtUnpacking(nil))); got != want { - t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got) - } - - // A symlink under the templates directory is refused by its path, as under files/. - if err := os.Symlink("/etc/hostname", filepath.Join(dir, "templates", "elsewhere")); err != nil { - t.Fatal(err) - } - if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "templates/elsewhere is a symlink") { - t.Fatalf("a symlink under templates/ was not refused by its path: %v", err) - } -} - -func TestATemplatesDirectoryNamedFilesIsTheDefault(t *testing.T) { - raw := strings.Replace(templatedTwin, `"version":"1",`, `"version":"1","templates":"files",`, 1) - if got, want := composed(t, held(t, raw, builtFiles(templateTexts(t)))), - composed(t, held(t, inlineTwin, builtFiles(nil))); got != want { - t.Fatalf("naming files/ as the templates directory composes differently:\n%s\n%s", got, want) - } -} diff --git a/go/catalogue/terminal_settings_test.go b/go/catalogue/terminal_settings_test.go index 4086479..84d30cb 100644 --- a/go/catalogue/terminal_settings_test.go +++ b/go/catalogue/terminal_settings_test.go @@ -5,75 +5,6 @@ import ( "testing" ) -// A line break, a carriage return or a NUL in any string of any setting is refused, at any depth, keys too — -// where a layer is judged, which is both where it is kept and where it is composed (novox/hq issue 339). -func TestASettingHoldsOneLine(t *testing.T) { - m := Manifest{Module: "mailu"} - for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", []any{"ok", "x\ny"}, - map[string]any{"k": []any{"ok", map[string]any{"deep": "x\ny"}}}, map[string]any{"k\nx": "v"}} { - if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil || - !strings.Contains(err.Error(), "line break") { - t.Errorf("%q: %v", v, err) - } - } - if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v\nPATH": "x"}}}, false); err == nil { - t.Error("a line break in a key was taken") - } - if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": "one line", "n": 3.0, - "l": []any{"a", "b"}}}}, false); err != nil { - t.Errorf("one line each: %v", err) - } -} - -// Lines are allowed in one setting only: step-ca's `root`, as certificates that encoding/pem decodes and -// x509.ParseCertificate parses (novox/hq issue 339). Any other module or key, another label, or a block that is -// not a certificate is refused like any other line break. -func TestOnlyTheAuthoritysRootMayHoldLines(t *testing.T) { - ca := Manifest{Module: "step-ca"} - judge := func(m Manifest, key, v string) error { - return JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{key: v}}}, false) - } - for _, ok := range []string{servedRoot, servedRoot + servedRoot, strings.TrimSuffix(servedRoot, "\n")} { - if err := judge(ca, "root", ok); err != nil { - t.Errorf("the authority's root: %v", err) - } - } - body := strings.TrimSuffix(strings.TrimPrefix(servedRoot, "-----BEGIN CERTIFICATE-----\n"), "-----END CERTIFICATE-----\n") - for name, bad := range map[string]string{ - "a line after it": servedRoot + "PATH=/tmp\n", - "a line before it": "PATH=\n" + servedRoot, - "another label": "-----BEGIN PRIVATE KEY-----\n" + body + "-----END PRIVATE KEY-----\n", - "headers": "-----BEGIN CERTIFICATE-----\nProc-Type: 4,ENCRYPTED\n\n" + body + "-----END CERTIFICATE-----\n", - "base64 that is no cert": "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQfake000000000000000000000000\n-----END CERTIFICATE-----\n", - "carriage returns": strings.ReplaceAll(servedRoot, "\n", "\r\n"), - } { - if err := judge(ca, "root", bad); err == nil { - t.Errorf("%s was taken", name) - } - } - if err := judge(ca, "other", servedRoot); err == nil { - t.Error("a certificate in another key of the authority was taken") - } - if err := judge(Manifest{Module: "mailu"}, "root", servedRoot); err == nil { - t.Error("a certificate in another module's setting was taken") - } - if err := JudgeSettings(ca, []Layer{{From: "anchor", Values: map[string]any{"root": []any{servedRoot}}}}, false); err == nil { - t.Error("a certificate below the top of the setting was taken") - } -} - -// Every character a reader takes as the end of a line is refused, not only \n and \r: vertical tab, form feed, -// NEL and the Unicode line and paragraph separators (novox/hq issue 339). -func TestEveryLineEndIsRefused(t *testing.T) { - m := Manifest{Module: "mailu"} - for _, v := range []string{"a\vb", "a\fb", "a\u0085b", "a\u2028b", "a\u2029b"} { - if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil || - !strings.Contains(err.Error(), "line break") { - t.Errorf("%q: %v", v, err) - } - } -} - // What a provider serves its consumers is the terminal's (novox/hq issue 339): any key under its `serves`, and any // setting a served value asks for, is set at the terminal alone — a verb that could change a port could point every // consumer at a listener of the caller's own. So is any setting a file marked `trusted` asks for. diff --git a/go/catalogue/toolchain_manifests_test.go b/go/catalogue/toolchain_manifests_test.go index dc5d09a..33cd4dd 100644 --- a/go/catalogue/toolchain_manifests_test.go +++ b/go/catalogue/toolchain_manifests_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -9,15 +8,6 @@ import ( // them with the machine's reach, so a node without one refuses the module that needs it at resolution, naming // the provision, and does not find out at its first build. The manifests are the catalogue's, as they are. -// builderNode is a machine that can run a build module: the package manager and user namespaces it asks of -// the machine, and the forge account (mesh-scope, provided by the forge) and the artifact store the build -// modules publish to (ADR 0513, provided by distribution) stood in for, and node-tools, the tool runner they pull their -// asks through (mesh-catalog issue 1911; mesh-tools' module, not in the catalogue). -func builderNode() Node { - return Node{Name: "builder", Site: "house", - Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}} -} - func toolchainShelf(t *testing.T) map[string]Manifest { t.Helper() m := map[string]Manifest{ @@ -29,49 +19,6 @@ func toolchainShelf(t *testing.T) map[string]Manifest { return m } -func TestAModuleNeedingAToolchainIsRefusedOnANodeWithoutIt(t *testing.T) { - for _, c := range []struct{ module, provision string }{ - {"mesh-build-go", "go-toolchain"}, - {"mesh-build-node", "node-toolchain"}, - {"dev-tools", "go-toolchain"}, - } { - _, err := Resolve(toolchainShelf(t), []string{"forge", c.module}, builderNode(), World{}) - if err == nil { - t.Fatalf("%s composed on a node with no toolchain module", c.module) - } - for _, want := range []string{`"` + c.provision + `" is wanted by ` + c.module, - "usable only on the machine that provides it"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("%s: the refusal does not say %q:\n%v", c.module, want, err) - } - } - } -} - -func TestAModuleNeedingAToolchainComposesOnANodeWithIt(t *testing.T) { - for _, c := range []struct{ module, owner string }{ - {"mesh-build-go", "go"}, - {"mesh-build-node", "nodejs"}, - {"dev-tools", "go"}, - } { - assigned := []string{"forge", c.owner, c.module} - if c.module == "dev-tools" { - assigned = append(assigned, "nodejs") - } - got, err := Resolve(toolchainShelf(t), assigned, builderNode(), World{}) - if err != nil { - t.Fatalf("%s with %s did not compose: %v", c.module, c.owner, err) - } - in := map[string]bool{} - for _, n := range names(got) { - in[n] = true - } - if !in[c.module] || !in[c.owner] { - t.Errorf("%s with %s composed %v", c.module, c.owner, names(got)) - } - } -} - // Only the owners declare the packages: a build module that declared go again would compose without the // owner's provision being met by anything, which is the gap the provision closes. func TestOnlyTheToolchainModulesDeclareTheToolchainPackages(t *testing.T) { diff --git a/go/catalogue/two_consumers_test.go b/go/catalogue/two_consumers_test.go deleted file mode 100644 index f989781..0000000 --- a/go/catalogue/two_consumers_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// A node runs several modules that all want one database (novox/hq 04-ISSUES/022). -// -// **The ordinary arrangement, and it could not be planned at all.** The walk that resolves a node -// is a work-list over names, so a requirement three modules shared was visited once and produced -// one need — carrying whichever module mentioned it first. Everything downstream inherited that: -// one credential, keyed by machine, named after a machine by the provisioner. -// -// The symptom had two halves and only one was loud. The provider refused, naming the modules and -// saying they would share one credential, which reads as a decision. The consumer did not: it -// resolved cleanly, wrote one module's credential file, and left the other two absent — a service -// that starts and cannot authenticate, with nothing anywhere saying why. That is the shape of 021 -// again, on a different axis. - -func threeConsumers() map[string]Manifest { - return shelf( - Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")}, - Manifest{Module: "gitea", Version: "1", Requires: []string{"postgres-database"}, - Contributes: map[string]map[string]any{"postgres-database": {"name": "gitea"}}, - Secrets: map[string]string{"postgres-database": "/var/lib/gitea/db.secret"}}, - Manifest{Module: "keycloak", Version: "1", Requires: []string{"postgres-database"}, - Contributes: map[string]map[string]any{"postgres-database": {"name": "keycloak"}}, - Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}}, - Manifest{Module: "umami", Version: "1", Requires: []string{"postgres-database"}, - Contributes: map[string]map[string]any{"postgres-database": {"name": "umami"}}, - Secrets: map[string]string{"postgres-database": "/var/lib/umami/db.secret"}}, - ) -} - -func TestEveryConsumerOnANodeGetsItsOwnCredential(t *testing.T) { - got, err := Resolve(threeConsumers(), []string{"gitea", "keycloak", "umami"}, - reachable(), World{Offered: onNetwork("anchor")}) - if err != nil { - t.Fatal(err) - } - if len(got.Needs) != 3 { - t.Fatalf("three modules want a database and the node has %d need(s): %v", - len(got.Needs), got.Needs) - } - for _, want := range []string{"gitea", "keycloak", "umami"} { - var found bool - for _, n := range got.Needs { - if n.For == want { - found = true - } - } - if !found { - t.Errorf("%s wants a database and no credential is made for it", want) - } - } -} - -// Each consumer's own credential reaches its own file. Matching on the provision alone, every -// consumer took whichever need was last — a module handed somebody else's password, which is a -// valid credential and therefore fails in a way that looks like a configuration error. -func TestEachConsumerGetsItsOwnCredentialAndNotAnothersFile(t *testing.T) { - got, err := Resolve(threeConsumers(), []string{"gitea", "keycloak", "umami"}, - reachable(), World{Offered: onNetwork("anchor")}) - if err != nil { - t.Fatal(err) - } - for i := range got.Needs { - got.Needs[i].Sealed = "sealed-for-" + got.Needs[i].For - } - out, err := got.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"gitea", "keycloak", "umami"} { - var seen bool - for _, r := range out { - if r["path"] != "/var/lib/"+want+"/db.secret" { - continue - } - seen = true - if r["sealed"] != "sealed-for-"+want { - t.Errorf("%s was given %v, which belongs to something else", want, r["sealed"]) - } - } - if !seen { - t.Errorf("%s resolved and its credential file was never written", want) - } - } -} - -// The provider is told about all three, separately, and names each grant after the module. -func TestAProviderIsToldAboutEveryConsumerOnOneMachine(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) - } - - var grants []Grant - for _, who := range []string{"gitea", "keycloak", "umami"} { - grants = append(grants, Grant{ - Provision: "postgres-database", Consumer: "anchor", From: who, - Values: map[string]any{"name": who}, Sealed: "sealed-for-" + who}) - } - out, err := provider.Declaration(Rendering{Grants: grants}) - if err != nil { - t.Fatal(err) - } - - for _, who := range []string{"gitea", "keycloak", "umami"} { - path := "/var/lib/postgres/grants/anchor." + who + ".secret" - var found bool - for _, r := range out { - if r["path"] == path { - found = true - if r["sealed"] != "sealed-for-"+who { - t.Errorf("%s's grant holds %v", who, r["sealed"]) - } - } - } - if !found { - t.Errorf("the provider was never told to create a login for %s", who) - } - } - - // And all three appear in the readable manifest, so the provisioner sees three consumers - // where there are three. Named after one machine, they were one path and the last won. - for _, r := range out { - if r["path"] != "/var/lib/postgres/grants/mesh.json" { - continue - } - body := r["content"].(string) - for _, who := range []string{"gitea", "keycloak", "umami"} { - if !strings.Contains(body, `"`+who+`"`) { - t.Errorf("the provider's manifest never mentions %s: %s", who, body) - } - } - } -} diff --git a/go/catalogue/two_resolvers_test.go b/go/catalogue/two_resolvers_test.go index f68bb95..dd08989 100644 --- a/go/catalogue/two_resolvers_test.go +++ b/go/catalogue/two_resolvers_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -66,76 +65,6 @@ func worldWithout(node string) World { return w } -// resolvConfOn resolves and composes one machine and answers with the nameservers its resolver file -// lists, in order, and the file. The file is the uplink's — `uplink` is one of the assigned modules — -// and nothing else on the machine declares that path. -func resolvConfOn(t *testing.T, node, uplink string, assigned []string) ([]string, string) { - t.Helper() - got, err := Resolve(twoResolverShelf(t), assigned, managing(node), worldWithout(node)) - if err != nil { - t.Fatalf("%s with %s does not resolve with two resolvers on record: %v", node, uplink, err) - } - out, err := got.Declaration(Rendering{ - Names: resolverMachines, Machines: resolverMachines, Suffix: "internal", - Holders: map[string]map[string]string{"mesh-dns-resolver": { - "anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}}, - Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, - }) - if err != nil { - t.Fatalf("%s with %s does not compose: %v", node, uplink, err) - } - var file map[string]any - for _, r := range out { - if r["path"] != "/etc/resolv.conf" { - continue - } - if file != nil { - t.Fatalf("%s declares /etc/resolv.conf twice: %v and %v", node, file["id"], r["id"]) - } - file = r - } - if file == nil || file["id"] != uplink+".fact-resolvers" { - t.Fatalf("%s's resolver file is not %s's: %v", node, uplink, file) - } - content, _ := file["content"].(string) - var servers []string - for _, line := range strings.Split(content, "\n") { - if strings.HasPrefix(line, "nameserver ") { - servers = append(servers, strings.TrimPrefix(line, "nameserver ")) - } - } - return servers, content -} - -// Per uplink module: each writes a resolver file listing both holders, the machine's own first on a -// holder, and only the holders on a machine that is none. -func TestEveryUplinkListsBothResolversItsOwnFirst(t *testing.T) { - for _, uplink := range uplinks { - for node, want := range map[string][]string{ - "anchor": {"10.42.0.1", "10.42.0.3"}, - "home": {"10.42.0.3", "10.42.0.1"}, - "laptop": {"10.42.0.1", "10.42.0.3"}, - } { - assigned := []string{uplink} - if node != "laptop" { - assigned = append(assigned, "dnsmasq") - } - servers, content := resolvConfOn(t, node, uplink, assigned) - if strings.Join(servers, " ") != strings.Join(want, " ") { - t.Errorf("%s on %s lists %v; want %v\n%s", uplink, node, servers, want, content) - } - for _, public := range []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"} { - if strings.Contains(content, public) { - t.Errorf("%s on %s lists a public resolver beside the mesh's (ADR 0223):\n%s", uplink, node, content) - } - } - if !strings.HasSuffix(content, "\noptions timeout:1 attempts:2 edns0\n") { - t.Errorf("%s on %s does not end with two short attempts:\n%s", uplink, node, content) - } - } - } -} - // One file, whichever manager the machine runs: the three modules carry the same template, so a // machine changing its manager changes nothing in what it asks, and a fix made to one is made to all. func TestEveryUplinkWritesTheSameResolverFile(t *testing.T) { @@ -155,66 +84,6 @@ func TestEveryUplinkWritesTheSameResolverFile(t *testing.T) { } } -// The requirement stays with what writes the file (ADR 0223 part 1): a machine is refused when nothing -// in the mesh resolves, rather than given a file listing nothing. -func TestEveryUplinkRequiresTheMeshsResolver(t *testing.T) { - for _, uplink := range uplinks { - found := false - for _, r := range catalogueManifest(t, uplink).Requires { - found = found || r == "wildcard-resolution" - } - if !found { - t.Errorf("%s writes the resolver file and does not require wildcard-resolution", uplink) - } - } - _, err := Resolve(twoResolverShelf(t), []string{"networkmanager"}, managing("laptop"), World{}) - if err == nil || !strings.Contains(err.Error(), "wildcard-resolution") { - t.Errorf("an uplink was composed on a mesh with no resolver: %v", err) - } -} - -// A holder answers its own requirement, even though the other holder sorts first (issue 258 kept). -func TestAHolderAnswersItsOwnRequirement(t *testing.T) { - got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq", "networkmanager"}, - managing("home"), worldWithout("home")) - if err != nil { - t.Fatal(err) - } - for _, n := range got.Needs { - if n.Name == "wildcard-resolution" && n.From != "home" { - t.Errorf("the home server's uplink is bound to %s; it holds the seat itself", n.From) - } - } -} - -// A seat held once is still held once: a second claimant on another machine is refused while -// nothing is on record, and a store recording two holders of it is refused, naming the seat. -func TestASingleHolderMeshSeatStillRefusesASecondHolder(t *testing.T) { - store := shelf(mod("postgres", nil, nil, nil, Claim{Name: "mesh-store", Scope: ScopeMesh})) - other := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "postgres"} - if _, err := Resolve(store, []string{"postgres"}, workstation(), World{Held: []Held{other}}); err == nil || - !strings.Contains(err.Error(), "one per mesh") { - t.Errorf("a second claimant of a seat held once was not refused: %v", err) - } - here := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: workstation().Name, Module: "postgres"} - _, err := Resolve(store, []string{"postgres"}, workstation(), - World{Held: []Held{other}, Holdings: []Held{other, here}}) - if err == nil || !strings.Contains(err.Error(), "on record as held by 2") { - t.Errorf("two holders on record for a seat held once were not refused: %v", err) - } -} - -// Replicated is not "whoever is assigned": two claimants with nothing on record are refused, as for -// any mesh seat, and each holder is added by an act. -func TestTwoUnrecordedClaimantsOfTheReplicatedSeatAreRefused(t *testing.T) { - w := worldWithout("home") - w.Holdings = nil - _, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "home", At: "home.internal"}, w) - if err == nil || !strings.Contains(err.Error(), "seat mesh-dns-resolver --to") { - t.Errorf("a second resolver with nothing on record was not refused, naming the handover: %v", err) - } -} - // Only the mesh's resolver is replicated: a seat being replicated is a decision, recorded. func TestOnlyTheResolverIsReplicated(t *testing.T) { for _, s := range Seats() { @@ -228,44 +97,3 @@ func TestOnlyTheResolverIsReplicated(t *testing.T) { t.Error("loading the set from the store, which has no column for it, lost the resolver's replication") } } - -// Every consumer is bound to the same holder whatever order the mesh resolved its machines in. -func TestTheFirstHolderIsTheFirstProvider(t *testing.T) { - providers := []Provider{{Node: "anchor", Module: "dnsmasq"}, {Node: "home", Module: "dnsmasq"}} - for _, held := range [][]Held{bothResolvers, {bothResolvers[1], bothResolvers[0]}} { - if p, ok := HolderAmong("wildcard-resolution", providers, held); !ok || p.Node != "anchor" { - t.Errorf("held in order %v answered %v", held, p) - } - } -} - -// One host record per machine, beside its wildcard (novox/hq issue 262): a name with a host record -// says it exists and has no IPv6 address, where the wildcard alone said there is no such name, and -// musl reads that as final. -func TestTheResolverHasOneHostRecordPerMachine(t *testing.T) { - got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor", At: "anchor.internal"}, - World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(Rendering{Names: resolverMachines, Machines: resolverMachines, Suffix: "internal", - Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}}) - if err != nil { - t.Fatal(err) - } - content, _ := byID(out)["dnsmasq.fact-node-zones"]["content"].(string) - records := map[string]int{} - for _, line := range strings.Split(content, "\n") { - if strings.HasPrefix(line, "host-record=") { - records[strings.TrimPrefix(line, "host-record=")]++ - } - } - for name, at := range resolverMachines { - if records[name+","+at] != 1 { - t.Errorf("%s has %d host records at %s, and has one:\n%s", name, records[name+","+at], at, content) - } - } - if len(records) != len(resolverMachines) { - t.Errorf("%d host records for %d machines:\n%s", len(records), len(resolverMachines), content) - } -} diff --git a/go/catalogue/unconsumed_placeholder.go b/go/catalogue/unconsumed_placeholder.go deleted file mode 100644 index 06e0c3b..0000000 --- a/go/catalogue/unconsumed_placeholder.go +++ /dev/null @@ -1,144 +0,0 @@ -package catalogue - -import ( - "fmt" - "regexp" - "strings" -) - -// filler is one namespace's pass: the patterns it fills with, and the fields it reads them in, by -// resource type ("*" for any type). -type filler struct { - namespace string - patterns []*regexp.Regexp - fields map[string][]string - // why, when set, is the rule that keeps the namespace out of every other field, said with a - // refusal of one written there. - why string -} - -// fillers is the table of what each pass fills where — read from the passes themselves: settingInto, -// dirInto, accessInto, intoFile (whose ${secret:…} the node-engine fills), boundInto, portInto, -// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here, -// or the sweep refuses the placeholder it would have filled. -var fillers = []filler{ - {namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}}, - {namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, - {namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, - {namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}}, - why: "a secret is never filled into an environment variable or any other field: put it in a file the " + - "module declares and mount that (novox/hq ADR 0086)"}, - {namespace: "bound", patterns: []*regexp.Regexp{bound}, fields: map[string][]string{"file": {"content"}}}, - {namespace: "port", patterns: []*regexp.Regexp{ofPort}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, - {namespace: "seat", patterns: []*regexp.Regexp{ofSeat, ofSeatReach}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, - {namespace: "machine", patterns: []*regexp.Regexp{ofMachine}, fields: map[string][]string{"*": {"path", "owner", "content", "name", "user", "root", "home"}}}, - // Placed in a file's content by their holders, and judged there by their own rules - // (placeholderProblems, seatPlaceholderProblems). - {namespace: "environment", patterns: []*regexp.Regexp{ofEnvironment}, fields: map[string][]string{"*": {"content"}}}, - {namespace: "shell", patterns: []*regexp.Regexp{ofShell}, fields: map[string][]string{"*": {"content"}}}, - {namespace: "contribution", patterns: []*regexp.Regexp{ofContribution}, fields: map[string][]string{"*": {"content"}}}, - // What the machine's modules back up, placed by the backup holder's file (backup_placement.go, issue 794). - {namespace: BackupNamespace, patterns: []*regexp.Regexp{ofBackup}, fields: map[string][]string{"*": {"content"}}}, - // A collection, placed by its collector (collections.go): filled by the provisioner's client. - {namespace: CollectedNamespace, patterns: []*regexp.Regexp{ofCollected}, fields: map[string][]string{"*": {"content"}}}, - // A provider's consumers' credentials (consumers.go): filled by the provisioner's client. - {namespace: ConsumersNamespace, patterns: []*regexp.Regexp{ofConsumers}, fields: map[string][]string{"file": {"content"}}}, - // Filled in what a provider serves (consumer_into_serves.go), never in a resource. - {namespace: "consumer", patterns: []*regexp.Regexp{consumerFact}}, -} - -// reads is whether this pass fills a field of a resource of this type. -func (f filler) reads(kind, field string) bool { - return oneOf(f.fields[kind], field) || oneOf(f.fields["*"], field) -} - -// ofKnownNamespace is any `${:` and what follows it up to its brace or the end of -// its line, whatever its case, unless what follows the colon is a shell's parameter operator (`-`, -// `=`, `+`, `?`): what remains of one once every pass's pattern is set aside is a misspelling. -var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") + - `):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`) - -// namespaceShaped is a placeholder in the mesh's shape: a lower-case word, a colon, and a key that -// begins with a letter or a digit and holds no space, brace or `$`. -var namespaceShaped = regexp.MustCompile(`\$\{[a-z][a-z0-9_-]*:[A-Za-z0-9][^\s{}$]*\}`) - -func namespacesOf(fs []filler) []string { - out := make([]string, len(fs)) - for i, f := range fs { - out[i] = f.namespace - } - return out -} - -// unconsumedPlaceholders is every placeholder in one resource of a definition that no pass fills -// where it stands, each named with the module, the resource, the field and the token. The same -// function at the catalogue check and at composition, over the resource as the manifest wrote it. -func unconsumedPlaceholders(module string, r map[string]any) []string { - kind := fmt.Sprint(r["type"]) - var problems []string - var sweep func(top, field string, v any) - sweep = func(top, field string, v any) { - switch v := v.(type) { - case string: - rest := v - for _, f := range fillers { - for _, p := range f.patterns { - if !f.reads(kind, top) { - for _, token := range p.FindAllString(rest, -1) { - problems = append(problems, fmt.Sprintf( - "%s's resource %v holds %s in its %s, and ${%s:…} is not filled in this field: "+ - "it would reach the machine as that text (novox/hq issue 231)%s", - module, r["id"], token, field, f.namespace, whereFilled(f))) - } - } - rest = p.ReplaceAllString(rest, "") - } - } - for _, token := range ofKnownNamespace.FindAllString(rest, -1) { - problems = append(problems, fmt.Sprintf( - "%s's resource %v holds %s in its %s, which is no placeholder the mesh fills: a "+ - "misspelt key, or a namespace in the wrong case, would reach the machine as that "+ - "text (novox/hq issue 231)", module, r["id"], token, field)) - } - rest = ofKnownNamespace.ReplaceAllString(rest, "") - for _, token := range namespaceShaped.FindAllString(rest, -1) { - problems = append(problems, fmt.Sprintf( - "%s's resource %v holds %s in its %s, and no pass of the mesh fills it: a misspelt "+ - "placeholder would reach the machine as that text (novox/hq issue 231). The mesh "+ - "fills ${%s:…}; the shell's own syntax belongs in the module's shell code (ADR 0204)", - module, r["id"], token, field, strings.Join(namespacesOf(fillers), ":…}, ${"))) - } - case []any: - for i, e := range v { - sweep(top, fmt.Sprintf("%s[%d]", field, i), e) - } - case map[string]any: - for _, k := range sortedKeys(v) { - sweep(top, field+"."+k, v[k]) - } - } - } - for _, k := range sortedKeys(r) { - sweep(k, k, r[k]) - } - return problems -} - -// whereFilled says where a namespace's pass does fill, for a refusal of one written elsewhere. -func whereFilled(f filler) string { - if f.why != "" { - return ". " + strings.ToUpper(f.why[:1]) + f.why[1:] - } - if len(f.fields) == 0 { - return "; it is filled only in what a provider serves" - } - var where []string - for _, kind := range sortedKeys(f.fields) { - of := "a " + kind + "'s" - if kind == "*" { - of = "any resource's" - } - where = append(where, of+" "+strings.Join(f.fields[kind], ", ")) - } - return "; it is filled in " + strings.Join(where, "; ") -} diff --git a/go/catalogue/unconsumed_placeholder_test.go b/go/catalogue/unconsumed_placeholder_test.go deleted file mode 100644 index bd9b09f..0000000 --- a/go/catalogue/unconsumed_placeholder_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// novox/hq issue 231 — a misspelled placeholder is written out as text. -// -// The four placeholders of the issue, in one file: two misspelled namespaces, a setting key no -// definition could declare, and the shell's own syntax. The first three are refused by name, at the -// catalogue check and at composition; the fourth reaches the file as written. -const ( - misspelledShell = "${shel:zsh:first}" - undeclaredSetting = "${setting:Undeclared}" - misspelledMachine = "${machnie:address}" - shellsOwn = "${XDG_CACHE_HOME:-x}" - // The shell's operators after a word the mesh also uses, in any case: the shell's, and passed. - shellsOperators = "${PORT:-8080} ${SHELL:-/bin/sh} ${SECRET:?unset} ${dir:-/tmp}" -) - -// Composition refuses the same placeholders in the same words — a manifest the store already holds -// was never parsed by this binary — and a namespace the mesh knows, written in a field its pass does -// not read, is refused there too, because it would reach the machine as the same literal text. -func TestAMisspelledPlaceholderIsRefusedAtComposition(t *testing.T) { - for _, c := range []struct { - field string - r map[string]any - token string - }{ - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "a=" + misspelledShell + "\n"}, misspelledShell}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "b=" + undeclaredSetting + "\n"}, undeclaredSetting}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "c=" + misspelledMachine + "\n"}, misspelledMachine}, - {"env.NAME", map[string]any{"id": "rc", "type": "process", "name": "speller", "env": map[string]any{"NAME": "${machine:name}"}}, "${machine:name}"}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${Machine:address}"}, "${Machine:address}"}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${machine:.address}"}, "${machine:.address}"}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${machine: address}"}, "${machine: address}"}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${Dir:x}"}, "${Dir:x}"}, - {"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "a=${machine:address\nb=1"}, "${machine:address"}, - } { - m := Manifest{Module: "speller", Version: "1", Resources: []map[string]any{c.r}} - r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}} - _, err := r.Declaration(Rendering{}) - if err == nil || !strings.Contains(err.Error(), "speller's resource rc holds "+c.token+" in its "+c.field) { - t.Errorf("%s in its %s was composed rather than refused by name: %v", c.token, c.field, err) - } - } - - m := Manifest{Module: "speller", Version: "1", Resources: []map[string]any{ - {"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "d=" + shellsOwn + " " + shellsOperators + "\n"}, - {"id": "server", "type": "process", "name": "server", "env": map[string]any{"PORT": "${PORT:-80}"}}, - }} - out, err := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}.Declaration(Rendering{}) - if err != nil { - t.Fatalf("the shell's own syntax was refused: %v", err) - } - if got := contentOf(t, out, "speller.rc"); got != "d="+shellsOwn+" "+shellsOperators+"\n" { - t.Fatalf("the shell's own syntax did not pass through as written: %q", got) - } -} - -// contentOf is the content of the composed resource with this id, failing when it was not composed. -func contentOf(t *testing.T, out []map[string]any, id string) string { - t.Helper() - for _, res := range out { - if res["id"] == id { - return plainly(res["content"]) - } - } - t.Fatalf("%s was not composed: %v", id, out) - return "" -} - -// What a value puts into a file is its software's text, not the definition's, and is never swept -// (review of mesh-controller #211): an operator's setting holding `${labels:instance}` filled through -// ${setting:…}, and a JSON setting `${level:upper}` merged into a mergeable file, reach the machine as -// set — software that templates its own configuration (Log4j's `${env:…}`, Spring's `${timeout:30}`) -// is configured exactly this way. -func TestAValueHoldingAPlaceholderShapeComposesUnchanged(t *testing.T) { - m := Manifest{Module: "templater", Version: "1", Resources: []map[string]any{ - {"id": "conf", "type": "file", "path": "/etc/templater.conf", "content": "template=${setting:template}\n"}, - {"id": "json", "type": "file", "path": "/etc/templater.json", "merge": MergeJSON, "content": `{"fmt":"plain"}`}, - }} - settings := SettingsBy{"templater": {{From: "the operator", Values: map[string]any{ - "template": "${labels:instance}", "fmt": "${level:upper}", - }}}} - out, err := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}.Declaration(Rendering{Settings: settings}) - if err != nil { - t.Fatalf("a value holding a placeholder's shape was refused as the definition's: %v", err) - } - if got := contentOf(t, out, "templater.conf"); got != "template=${labels:instance}\n" { - t.Errorf("the setting did not reach the file as set: %q", got) - } - if got := contentOf(t, out, "templater.json"); !strings.Contains(got, `${level:upper}`) { - t.Errorf("the merged setting did not reach the file as set: %q", got) - } -} - -// Contributed shell code is the shell's, and no pass reads it (novox/hq ADR 0204): zsh's own -// `${path:t}` is namespace-shaped, and reaches the holder's file untouched. -func TestContributedShellCodeIsNotSwept(t *testing.T) { - modules := []Manifest{zshHolder(), {Module: "modifier", Shell: []ShellCode{ - {For: "zsh", Slot: "normal", Code: "echo ${path:t} ${shel:zsh:first}"}, - }}} - out, err := Resolution{Node: "workstation", Account: "op", Modules: modules}.Declaration(Rendering{}) - if err != nil { - t.Fatalf("contributed shell code was swept: %v", err) - } - if got := contentOf(t, out, "zsh.zshrc"); !strings.Contains(got, "echo ${path:t} ${shel:zsh:first}") { - t.Fatalf("the contributed code did not reach the holder's file as written: %q", got) - } -} diff --git a/go/catalogue/unknown_field.go b/go/catalogue/unknown_field.go index f6565bc..5721847 100644 --- a/go/catalogue/unknown_field.go +++ b/go/catalogue/unknown_field.go @@ -42,35 +42,6 @@ func asUnknownField(err error) *UnknownFieldError { return nil } -// UnknownFieldReason is why a module whose stored manifest has a key this reader does not know is left out of a -// machine's declaration, or "" when it is not. **A field set aside no longer leaves the module out** (novox/hq ADR 0578 -// §6, which changes ADR 0262 here): the lenient decoder reads the rest, the reader names the field (SetAside), and the -// part that needs the field takes the newer package. Only a manifest not read past the field, which holds its name and -// version alone (ReadNoFurther), is still left out, since nothing of it could be composed. -func UnknownFieldReason(m Manifest) string { - if !m.ReadNoFurther() { - return "" - } - return m.Module + " uses a field this controller does not know (" + m.unknown + "); it is left out " + - "until the controller is updated: nothing of it is changed on its machines and its contributions to " + - "other modules and its open ports stop. Its data is still backed up as this controller reads it, " + - "which may not be what its newer manifest asks, and it still provides what it provides " + - "(novox/hq ADR 0262)" -} - -// backupView is what of a left-out module still reaches its machine: its data, so the backup holder -// keeps copying it, and the directories and accesses its data items name. No contribution, shell code -// or environment of its own: those are what leaving it out stops. -func backupView(m Manifest) Manifest { - view := Manifest{Module: m.Module, Data: m.Data, Accesses: m.Accesses, bestEffort: true} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "directory" { - view.Resources = append(view.Resources, r) - } - } - return view -} - // prunedFields is a stored manifest's fields with every key this controller does not know taken out, // and the keys taken out (novox/hq ADR 0262). A second pass, after the strict one refused: each // top-level field is decoded alone, and where an entry inside it has an unknown key, the one diff --git a/go/catalogue/while_stopped_test.go b/go/catalogue/while_stopped_test.go index 8c47837..0f5a0dc 100644 --- a/go/catalogue/while_stopped_test.go +++ b/go/catalogue/while_stopped_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "fmt" "strings" "testing" ) @@ -35,58 +34,3 @@ func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) { t.Fatalf("a step holding its own module's container still was refused: %v", err) } } - -// A composed declaration names the step's held containers the way the machine knows them. -// -// **The gap that let a bug through to the control node.** The manifest says `while-stopped: -// ["store"]`, because a module names its own resources locally; the declaration a machine -// receives calls that container `distribution.store`, because every resource is composed under -// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and -// `while-stopped` was not — so the host found no container by that id and refused the whole -// declaration, every push, until it was fixed. -// -// It passed every test on both sides: the controller's tests read manifests, the host's read -// hand-written declarations with bare ids. Only composing one and judging the result catches it. -func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) { - store := Manifest{ - Module: "distribution", Version: "1", - Provides: FromAnywhere("artifact-store"), - Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}}, - Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}, - Resources: []map[string]any{ - {"id": "store", "type": "container", "name": "mesh-registry", - "image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}}, - {"id": "collect", "type": "container", "name": "mesh-registry-collect", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", WhileStopped: []any{"store"}}, - }, - } - r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := r.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - collect := fileNamed(out, "distribution.collect") - if collect == nil { - for _, res := range out { - if res["id"] == "distribution.collect" { - collect = res - } - } - } - if collect == nil { - t.Fatalf("the step was not composed at all: %v", out) - } - held, _ := collect[WhileStopped].([]any) - if len(held) != 1 { - t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped]) - } - if got := fmt.Sprint(held[0]); got != "distribution.store" { - t.Fatalf("the composed step says it holds %q still, and the machine's container is "+ - "called %q — the host refuses a declaration naming a container it does not have, "+ - "whole, so the machine would take nothing at all", got, "distribution.store") - } -} diff --git a/go/catalogue/yields.go b/go/catalogue/yields.go index afedce1..abbd692 100644 --- a/go/catalogue/yields.go +++ b/go/catalogue/yields.go @@ -1,9 +1,5 @@ package catalogue -import ( - "strconv" -) - // The build processor share's names (novox/hq ADR 0553 §3, §4), written here as well as in the build modules' package // (BuildCPUShareSetting, ShareWord, DefaultPercent), so a manifest reader does not reach the build runner's code; a // test of the build modules keeps them agreeing. @@ -15,65 +11,3 @@ const ( // BuildCPUDefaultPercent is the share where nothing sets one. BuildCPUDefaultPercent = 50 ) - -// yieldsOf is how the runtime of its own a module is served by gives way on this node (novox/hq ADR 0553), or nil when -// it does not, and the word its steps size their threads from: -// -// - a module holding a seat that yields (Seat.Yields, the build seats) runs at idle priority under the node's one share -// of its processors: the node-engine's own config build-cpu-share (BuildCPUShareSetting) as this node's layers give -// it, its default where none does. Every yielding module of the node carries the same share, so the node-engine's -// one yielding slice has one quota (ADR 0553 §2, §3). -// - its word: that share (BuildCPUShareWord), which a step divides by its own module's capacity (ADR 0553 §4). -// -// Never a refusal: a share out of range is the default, so a mistyped value bounds the builds at the default and never -// stops the node's whole composition. -func (r Resolution) yieldsOf(m Manifest, with Rendering) (map[string]any, map[string]string) { - if !yields(m) { - return nil, nil - } - engine := Manifest{Module: engineModule} - for _, e := range r.Modules { - if e.Module == engineModule { - engine = e - } - } - share := BuildCPUDefaultPercent - for _, s := range Effective(engine, with.Settings[engineModule]) { - if s.Key == BuildCPUShareSetting { - if n, ok := percentOf(s.Value); ok { - share = n - } - } - } - return map[string]any{"priority": "idle", "cpu-share": share}, - map[string]string{BuildCPUShareWord: strconv.Itoa(share)} -} - -// yields is whether a module claims a seat that yields. -func yields(m Manifest) bool { - for _, c := range m.Claims { - if s, ok := SeatNamed(c.Name); ok && s.Yields { - return true - } - } - return false -} - -// percentOf is a setting's value as a percent from 1 to 100: a number, or a string holding one. -func percentOf(v any) (int, bool) { - var n int - switch x := v.(type) { - case float64: - n = int(x) - case int: - n = x - case string: - var err error - if n, err = strconv.Atoi(x); err != nil { - return 0, false - } - default: - return 0, false - } - return n, n >= 1 && n <= 100 -} diff --git a/go/catalogue/zones.go b/go/catalogue/zones.go index c463573..d594b1a 100644 --- a/go/catalogue/zones.go +++ b/go/catalogue/zones.go @@ -1,11 +1,5 @@ package catalogue -import ( - "fmt" - "sort" - "strings" -) - // Zone is the manifest's declaration that a module answers the names in one zone. type Zone struct { // Name is the zone: a label or a dotted name, normally `${setting:}`, so the operator chooses @@ -23,57 +17,3 @@ type ZoneAt struct { Address string Port int } - -// ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the -// port its answering listen is published on there. Nothing when the module declares no zone. -func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) { - if m.Zone == nil { - return nil, nil - } - settled, err := Settle(map[string]any{"zone": m.Zone.Name}, layers) - if err != nil { - return nil, fmt.Errorf("%s's zone on %s: %w", m.Module, node, err) - } - zone := strings.Trim(strings.ToLower(fmt.Sprint(settled["zone"])), ".") - var port int - for _, l := range m.Listens { - if l.Name == m.Zone.Listen { - port = l.Port - if at, given := published[l.Port]; given { - port = at - } - } - } - return &ZoneAt{Zone: zone, Node: node, Module: m.Module, Address: address, Port: port}, nil -} - -// ZonesProblems is what the mesh refuses about its zones together: one zone declared twice, a zone -// that is the mesh's suffix or under it, a zone that is a node's public domain or under one. A module -// may not shadow names the mesh's resolver or the public DNS answers. -func ZonesProblems(zones []ZoneAt, suffix string, publicDomains []string) []string { - var problems []string - under := func(zone, domain string) bool { - domain = strings.Trim(strings.ToLower(domain), ".") - return domain != "" && (zone == domain || strings.HasSuffix(zone, "."+domain)) - } - seen := map[string]ZoneAt{} - for _, z := range zones { - if other, twice := seen[z.Zone]; twice && (other.Node != z.Node || other.Module != z.Module) { - problems = append(problems, fmt.Sprintf("zone %q is declared by %s on %s and by %s on %s; one module answers a zone", - z.Zone, other.Module, other.Node, z.Module, z.Node)) - } - seen[z.Zone] = z - if under(z.Zone, suffix) { - problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the mesh's own suffix or under it", - z.Module, z.Node, z.Zone)) - } - for _, d := range publicDomains { - if under(z.Zone, d) { - problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the public domain %q or under it", - z.Module, z.Node, z.Zone, d)) - } - } - } - sort.Strings(problems) - return problems -} diff --git a/go/catalogue/zones_test.go b/go/catalogue/zones_test.go index e3a1181..cca17bf 100644 --- a/go/catalogue/zones_test.go +++ b/go/catalogue/zones_test.go @@ -1,49 +1,9 @@ package catalogue import ( - "strings" "testing" ) -// The zone is the operator's (a setting) and the port is where this machine publishes the listen — -// neither is the definition's to state. -func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) { - m := Manifest{Module: "lab", Zone: &Zone{Name: "${setting:zone}", Listen: "dns"}, - Listens: []Listening{{Name: "dns", Port: 5353, From: FromMesh}}} - z, err := ZoneOn(m, []Layer{{From: "node", Values: map[string]any{"zone": "Incus."}}}, - map[int]int{5353: 15353}, "workstation", "10.77.0.3") - if err != nil { - t.Fatal(err) - } - if z.Zone != "incus" || z.Address != "10.77.0.3" || z.Port != 15353 || z.Node != "workstation" { - t.Fatalf("the zone was placed as %+v", *z) - } - if _, err := ZoneOn(m, nil, nil, "workstation", "10.77.0.3"); err == nil { - t.Fatal("a zone nobody named was placed") - } -} - -// One module answers a zone, and none may shadow the mesh's names or a public domain. -func TestTheMeshRefusesAZoneTwiceOrOneThatShadows(t *testing.T) { - one := ZoneAt{Zone: "incus", Node: "workstation", Module: "lab", Address: "10.77.0.3", Port: 53} - if p := ZonesProblems([]ZoneAt{one}, "internal", []string{"example.tld"}); len(p) != 0 { - t.Fatalf("one ordinary zone was refused: %v", p) - } - twice := one - twice.Node, twice.Module = "laptop", "other" - cases := map[string][]ZoneAt{ - "declared by": {one, twice}, - "mesh's own suffix": {{Zone: "lab.internal", Node: "a", Module: "m"}}, - "public domain": {{Zone: "dev.example.tld", Node: "a", Module: "m"}}, - } - for want, zones := range cases { - p := strings.Join(ZonesProblems(zones, "internal", []string{"example.tld"}), "\n") - if !strings.Contains(p, want) { - t.Errorf("not refused for %q: %q", want, p) - } - } -} - // The resolver's template sees every zone with where it is answered, in zone order. func TestTheResolversTemplateRangesTheZones(t *testing.T) { m := Manifest{Module: "dnsmasq", Facts: map[string]RosterFile{"zones": { diff --git a/go/judge_free_test.go b/go/judge_free_test.go index 86bb607..4441e9d 100644 --- a/go/judge_free_test.go +++ b/go/judge_free_test.go @@ -19,13 +19,7 @@ import ( // composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no // manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5). var composition = map[string]string{ - "catalogue/consumers.go:consumersPlaceholderProblems": "composition: rendering a consumer's template", - "catalogue/collections.go:collectedPlaceholderProblems": "composition: placeholders a contribution renders", - "catalogue/environment_into.go:placeholderProblems": "composition: placeholders a contribution renders", - "catalogue/seat_contributions.go:seatPlaceholderProblems": "composition: placeholders a contribution renders", - "catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template", - "catalogue/setting_takes.go:takenSettingsProblems": "composition: whether stored settings still compose", - "catalogue/template.go:HeldTemplateProblem": "composition: the take-in refusal of a resolved build", + "catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template", } // judgeProblems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 8d224e0..c33e3db 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -27,6 +27,10 @@ var meshSDKPackages = []string{ var computesNot = []string{"SourceFingerprint", "SourceFingerprint2", "NamedTree", "MeshPackagesOf", "PackageVersionsOf", "OtherPackages", "Compose", "CompositionsShadow", "JudgeMove", "Run"} +// composesNot are the methods of the composition, the node-engine's alone since its rules moved there (ADR 0593 §7): +// mesh-sdk renders no declaration and refuses no composition. +var composesNot = []string{"Resolution.Compose", "Resolution.Declaration", "Resolution.Rules", "Resolution.LeftOut"} + // ADR 0593 §1's check: mesh-sdk holds exactly the packages it keeps, and none of them computes what one owner alone // computes. func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { @@ -53,9 +57,22 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { return err } for _, decl := range file.Decls { - if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && slices.Contains(computesNot, fn.Name.Name) { + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + if fn.Recv == nil && slices.Contains(computesNot, fn.Name.Name) { t.Errorf("%s: %s computes what its owner alone computes (ADR 0593 §2, §7)", path, fn.Name.Name) } + if fn.Recv != nil { + recv := fn.Recv.List[0].Type + if star, isStar := recv.(*ast.StarExpr); isStar { + recv = star.X + } + if id, isID := recv.(*ast.Ident); isID && slices.Contains(composesNot, id.Name+"."+fn.Name.Name) { + t.Errorf("%s: %s.%s composes, which the node-engine alone does (ADR 0593 §7)", path, id.Name, fn.Name.Name) + } + } } return nil })