From 174c8b5d53736da8c30bf586c2af9fb2b5961bac Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 18:19:22 +0200 Subject: [PATCH] Judge every path the controller resolves for a directory or file where the plan is composed, so the merge gate refuses a directory in Docker's data (issue 496) --- internal/catalogue/dir_into.go | 5 +- internal/catalogue/placement.go | 79 ++++++++++++++++ internal/catalogue/resolved_path_test.go | 109 +++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 internal/catalogue/resolved_path_test.go diff --git a/internal/catalogue/dir_into.go b/internal/catalogue/dir_into.go index 585358e3..3a9a919f 100644 --- a/internal/catalogue/dir_into.go +++ b/internal/catalogue/dir_into.go @@ -257,7 +257,10 @@ func dirInto(resource map[string]any, dirs map[string]string, module string) err } resource["env-file"] = filled } - return nil + // **Placed, then judged** (novox/hq issue 496): the path a directory or file resolves to — the default + // layout, a place, a stated path, one beneath a ${dir:…} — is judged here, where every plan and the merge + // gate compose it, so a module whose directory lands in Docker's data is refused before it reaches a machine. + return judgeResolved(resource, module) } // unknownDirRefs is every ${dir:…} in the definition that names no directory the definition diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go index b552ba30..24d1000b 100644 --- a/internal/catalogue/placement.go +++ b/internal/catalogue/placement.go @@ -92,6 +92,81 @@ func systemPath(path string) string { return "" } +// Where no directory or file the controller resolves for a module may be, from any route (novox/hq issue 496). +// +// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to /docker: /var/lib/docker, +// every container's filesystem. systemPath judged only the `places` and `accesses` settings, so the default layout +// and a definition's own paths reached the merge gate unjudged; it composed every machine and passed, and only the +// node-engine refused the directory, at apply, failing the walk. So every path a directory or file resolves to is +// judged where the plan is composed, which is also where the gate composes. +// +// Not by systemPath's whole list: a module writes the machine's configuration in /etc and /usr by design (units, +// jails, a launcher), keeps run directories in /run, an account's keys in its .ssh, and the mesh places its own files +// for every module in /var/lib/mesh. Those are the node-engine's to judge with what only the machine knows (owners, +// links, where the runtimes really keep their data). Refused here is what no module ever writes, whoever owns it: +// the trees in neverWritten, the node-engine's own trees but by its own module, and a path that is one of the +// machine's roots or holds one. Each is refused by the node-engine too (its placement guard, issue 339), so nothing +// that applies today is refused here. +var ( + neverWritten = []string{"/boot", "/root", "/proc", "/sys", "/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", + "/var/spool", "/var/lib/docker", "/var/lib/containers", "/var/lib/containerd", "/opt"} + engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"} + // engineModule is the node-engine's own module, the one that places in engineTrees. + engineModule = "mesh-host" + // usrRoots are the roots below /usr that hold every program and library: never a module's directory itself. + usrRoots = []string{"/usr/bin", "/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", + "/usr/local/sbin", "/usr/sbin", "/usr/share"} +) + +// resolvedSystemPath says why a path resolved for one of module's directories or files is the machine's own, or "". +func resolvedSystemPath(path, module string) string { + if !strings.HasPrefix(path, "/") { + return "" + } + path = filepath.Clean(path) + in := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") } + for _, tree := range neverWritten { + if in(tree) { + return path + " is in " + tree + ", which no module writes in" + } + } + if module != engineModule { + for _, tree := range engineTrees { + if in(tree) { + return path + " is in " + tree + ", the node-engine's own, which only its module places in" + } + } + } + for _, roots := range [][]string{systemRoots, systemTrees, usrRoots} { + for _, root := range roots { + if path == root { + return path + " is one of the machine's roots, the parent of everything in it" + } + } + } + for _, tree := range append(append([]string(nil), systemTrees...), neverWritten...) { + if strings.HasPrefix(tree, path+"/") { + return path + " holds " + tree + ", the machine's own or the mesh's state" + } + } + return "" +} + +// judgeResolved refuses a directory or file whose resolved path resolvedSystemPath refuses (novox/hq issue 496). +func judgeResolved(resource map[string]any, module string) error { + kind := fmt.Sprint(resource["type"]) + if kind != "directory" && kind != "file" { + return nil + } + path, _ := resource["path"].(string) + if why := resolvedSystemPath(path, module); why != "" { + return fmt.Errorf("%s's %s %q resolves to %s: %s. The node-engine refuses it at apply, so it is refused "+ + "where the plan is composed, and the merge gate with it (novox/hq issue 496)", + module, kind, fmt.Sprint(resource["id"]), filepath.Clean(path), why) + } + return nil +} + // accessRef is how a module names one of its accesses: ${access:}. var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`) @@ -284,6 +359,10 @@ func accessInto(resource map[string]any, accesses map[string]string, module stri if resource["path"], err = fill(path); err != nil { return err } + // Judged again once the access is in it: dirInto judged it while it still named one (novox/hq issue 496). + if err := judgeResolved(resource, module); err != nil { + return err + } } if content, ok := resource["content"].(string); ok { if resource["content"], err = fill(content); err != nil { diff --git a/internal/catalogue/resolved_path_test.go b/internal/catalogue/resolved_path_test.go new file mode 100644 index 00000000..4872bd45 --- /dev/null +++ b/internal/catalogue/resolved_path_test.go @@ -0,0 +1,109 @@ +package catalogue + +// Every path the controller resolves for a module's directories and files is judged where the plan is +// composed (novox/hq issue 496). mesh-catalog #205 gave docker's `state` directory `"place": "."`, which +// resolves to /docker — /var/lib/docker, every container's filesystem. The merge gate composed every +// machine and passed it; only the node-engine refused it, at apply, and failed the walk. The gate composes +// through Declaration, so a refusal here is a refusal at the gate, for every module. + +import ( + "strings" + "testing" +) + +func composedOn(m Manifest, with Rendering) ([]map[string]any, error) { + return Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(with) +} + +func pathOf(t *testing.T, resources []map[string]any, id string) string { + t.Helper() + for _, r := range resources { + if r["id"] == id { + return r["path"].(string) + } + } + t.Fatalf("no resource %s in %v", id, resources) + return "" +} + +func TestADirectoryPlacedInDockersDataIsRefusedAtComposition(t *testing.T) { + // The #205 shape, as it was merged. + m := Manifest{Module: "docker", Version: "1", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "."}, + }} + _, err := composedOn(m, Rendering{}) + if err == nil { + t.Fatal("a directory resolving to /var/lib/docker composed; the node-engine refuses it at apply") + } + for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} { + if !strings.Contains(err.Error(), said) { + t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err) + } + } +} + +func TestTheMeshsPlaceForDockerComposes(t *testing.T) { + // The fix #205 needed: the mesh's own directory for the module, /mesh/docker. + m := Manifest{Module: "docker", Version: "1", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "mesh"}, + {"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}, + }} + resources, err := composedOn(m, Rendering{}) + if err != nil { + t.Fatalf("place %q is the mesh's tree, which the mesh writes for every module: %v", "mesh", err) + } + if got := pathOf(t, resources, "docker.state"); got != "/var/lib/mesh/docker" { + t.Fatalf("got %q", got) + } +} + +func TestEveryResolvedPathIsJudged(t *testing.T) { + refused := map[string]Manifest{ + "a default directory under a data root inside containerd's data": {Module: "images", Version: "1", + Resources: []map[string]any{{"id": "cache", "type": "directory"}}}, + "a stated directory in docker's data": {Module: "sidecar", Version: "1", + Resources: []map[string]any{{"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}}}, + "a file beneath a placed directory that climbs out of it": {Module: "docker", Version: "1", + Resources: []map[string]any{{"id": "state", "type": "directory", "place": "mesh"}, + {"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}}}, + "a file in /boot": {Module: "grub", Version: "1", + Resources: []map[string]any{{"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}}}, + "a directory that is the data root itself": {Module: "lib", Version: "1", + Resources: []map[string]any{{"id": "all", "type": "directory", "place": "."}}}, + "a directory in the node-engine's own tree, by another module": {Module: "intruder", Version: "1", + Resources: []map[string]any{{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}}}, + } + roots := map[string]Rendering{ + "a default directory under a data root inside containerd's data": {DataRoot: "/var/lib/containerd"}, + "a directory that is the data root itself": {DataRoot: "/var"}, + } + for name, m := range refused { + if _, err := composedOn(m, roots[name]); err == nil || !strings.Contains(err.Error(), "issue 496") { + t.Errorf("%s: composed or refused for another reason: %v", name, err) + } + } + + // What the mesh's own modules place today, on every machine (read from each machine's live plan): the + // machine's configuration, the mesh's tree, the node-engine's own, run directories and an account's keys. + passes := map[string]Manifest{ + "a unit in /etc": {Module: "power", Version: "1", + Resources: []map[string]any{{"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"}, + {"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}}}, + "a run directory": {Module: "fail2ban", Version: "1", + Resources: []map[string]any{{"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}}}, + "a program in /usr/local": {Module: "claude-code", Version: "1", + Resources: []map[string]any{{"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}}}, + "the node-engine's launcher, by the node-engine": {Module: "mesh-host", Version: "1", + Resources: []map[string]any{{"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"}}}, + "an account's .ssh": {Module: "ssh-client", Version: "1", + Resources: []map[string]any{{"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"}, + {"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}}}, + "a module's own root": {Module: "mailu", Version: "1", + Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}}}, + } + for name, m := range passes { + if _, err := composedOn(m, Rendering{}); err != nil { + t.Errorf("%s: refused: %v", name, err) + } + } +}