From 2e3b13c0f83ea17408e0faf5abb477b7bb3d5742 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 18:18:13 +0200 Subject: [PATCH] The assignment's own root is a place, and the manifest's maps are placed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice two of ADR 0112. A pathless directory saying place "." is the assignment's one directory, / — to-be 27's shape — and place never reaches the host, which parses strictly. The maps naming where bindings, credentials and contributions land (binds, secrets, own-secrets, receives, grants) fill against the placed directories at composition, into fresh maps and a fresh module slice, because one resolution composes for many nodes. The five absolute-path checks on those maps accept a placed reference — resolution makes it absolute before anything reads it — while certificate, operator-keeps and accesses paths stay absolute-only: those are the operator's or another vocabulary's. unknownDirRefs scans the maps too, and validates place itself: only on a directory, only ".", never beside a stated path. Found by the foundation tests validating the sibling catalogue: the first conversion's blanket replace turned /var/lib/gitea/database.json into ${dir:data}base.json — which resolves to the right path by pure string concatenation. Production was saved by a coincidence; the catalogue cleanup that follows spells it ${dir:state}/database.json. --- internal/catalogue/declaration.go | 15 +++ internal/catalogue/dir_into.go | 141 +++++++++++++++++++++++++--- internal/catalogue/dir_into_test.go | 89 ++++++++++++++++++ internal/catalogue/manifest.go | 20 ++-- 4 files changed, 240 insertions(+), 25 deletions(-) diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 79dc773..ad33e90 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -224,6 +224,21 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { } func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) { + // 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{} diff --git a/internal/catalogue/dir_into.go b/internal/catalogue/dir_into.go index 5802e43..da5ca61 100644 --- a/internal/catalogue/dir_into.go +++ b/internal/catalogue/dir_into.go @@ -39,6 +39,11 @@ func dataRoot(with Rendering) string { } // dirsFor is every placed directory of a module, id → the path it resolves to on this node. +// +// A pathless directory saying `"place": "."` is the assignment's own root, / — +// to-be 27's one directory per assignment, which every other placed thing sits beneath. At most +// one makes sense; nothing enforces one, because two ids resolving to one path is a mistake the +// module's own files make visible immediately. func dirsFor(m Manifest, with Rendering) map[string]string { dirs := map[string]string{} for _, r := range m.Resources { @@ -50,11 +55,88 @@ func dirsFor(m Manifest, with Rendering) map[string]string { dirs[id] = strings.TrimRight(path, "/") continue } + if place, said := r["place"].(string); said && place == "." { + dirs[id] = dataRoot(with) + "/" + m.Module + continue + } dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id } return dirs } +// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or +// beginning with a placed reference — resolution makes it absolute before anything reads it. +// (unknownDirRefs is what checks the reference names a real directory.) +func placedOrAbsolute(path string) bool { + return strings.HasPrefix(path, "/") || + (strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path)) +} + +// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory. +func dirFill(s string, dirs map[string]string, module string) (string, error) { + var missing error + out := dirRef.ReplaceAllStringFunc(s, func(ref string) string { + id := dirRef.FindStringSubmatch(ref)[1] + path, has := dirs[id] + if !has { + missing = fmt.Errorf( + "%s says ${dir:%s}, and %s declares no directory %q. It declares %s", + module, id, module, id, orNothing(namesOfDirs(dirs))) + return ref + } + return path + }) + return out, missing +} + +// placedManifest is the manifest with every path the mesh resolves already resolved: the maps +// naming where bindings, credentials and contributions land are filled against this node's +// placed directories, so everything downstream — the generated binding files, the sealed +// secrets, the grant directories — reads a concrete place and learns nothing new. +func placedManifest(m Manifest, with Rendering) (Manifest, error) { + dirs := dirsFor(m, with) + fillMap := func(in map[string]string) (map[string]string, error) { + if len(in) == 0 { + return in, nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + filled, err := dirFill(value, dirs, m.Module) + if err != nil { + return nil, err + } + out[key] = filled + } + return out, nil + } + var err error + if m.Receives, err = fillMap(m.Receives); err != nil { + return m, err + } + if m.Binds, err = fillMap(m.Binds); err != nil { + return m, err + } + if m.Secrets, err = fillMap(m.Secrets); err != nil { + return m, err + } + if m.OwnSecrets, err = fillMap(m.OwnSecrets); err != nil { + return m, err + } + if m.Grants, err = fillMap(m.Grants); err != nil { + return m, err + } + if len(m.SecretsMany) > 0 { + many := make(map[string]map[string]string, len(m.SecretsMany)) + for to, locals := range m.SecretsMany { + if many[to], err = fillMap(locals); err != nil { + return m, err + } + } + m.SecretsMany = many + } + return m, nil +} + // dirInto places a resource: a pathless directory is given the path the mesh resolved for it, // and every ${dir:…} the resource carries — in its path, its content, its mounts, its // environment and its env-files — becomes that path. @@ -63,27 +145,16 @@ func dirsFor(m Manifest, with Rendering) map[string]string { // `${dir:x}` would reach the machine as a path, and the runtime would create and mount a // directory called `${dir:x}` — real, wrong, and named after the mistake. func dirInto(resource map[string]any, dirs map[string]string, module string) error { - fill := func(s string) (string, error) { - var missing error - out := dirRef.ReplaceAllStringFunc(s, func(ref string) string { - id := dirRef.FindStringSubmatch(ref)[1] - path, has := dirs[id] - if !has { - missing = fmt.Errorf( - "%s says ${dir:%s}, and %s declares no directory %q. It declares %s", - module, id, module, id, orNothing(namesOfDirs(dirs))) - return ref - } - return path - }) - return out, missing - } + fill := func(s string) (string, error) { return dirFill(s, dirs, module) } if fmt.Sprint(resource["type"]) == "directory" { id := fmt.Sprint(resource["id"]) if path, stated := resource["path"].(string); !stated || path == "" { resource["path"] = dirs[id] } + // Said in the catalogue, not on the machine: the host parses strictly and knows no + // such field — resolved, the place IS the path. + delete(resource, "place") } var err error @@ -157,6 +228,28 @@ func (m Manifest) unknownDirRefs() []string { return ids } var problems []string + for _, r := range m.Resources { + place, said := r["place"].(string) + if !said { + continue + } + if fmt.Sprint(r["type"]) != "directory" { + problems = append(problems, fmt.Sprintf( + "%s says place on %v, which is not a directory — only a directory is placed", + m.Module, r["id"])) + continue + } + if path, stated := r["path"].(string); stated && path != "" { + problems = append(problems, fmt.Sprintf( + "%s states both path and place on %v — a stated path IS the placement", + m.Module, r["id"])) + } + if place != "." { + problems = append(problems, fmt.Sprintf( + "%s says place %q on %v, and the only place is %q — the assignment's own root", + m.Module, place, r["id"], ".")) + } + } seen := map[string]bool{} refuse := func(id string, where any) { if declared[id] || seen[id] { @@ -197,6 +290,24 @@ func (m Manifest) unknownDirRefs() []string { } } } + maps := map[string]map[string]string{ + "receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets, + "own-secrets": m.OwnSecrets, "grants": m.Grants, + } + for field, entries := range maps { + for _, value := range entries { + for _, id := range referenced(value) { + refuse(id, field) + } + } + } + for to, locals := range m.SecretsMany { + for _, value := range locals { + for _, id := range referenced(value) { + refuse(id, "secrets."+to) + } + } + } sort.Strings(problems) return problems } diff --git a/internal/catalogue/dir_into_test.go b/internal/catalogue/dir_into_test.go index 661ae63..b1eeaaf 100644 --- a/internal/catalogue/dir_into_test.go +++ b/internal/catalogue/dir_into_test.go @@ -132,6 +132,95 @@ func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) { } } +func TestTheAssignmentsOwnRootIsAPlace(t *testing.T) { + m := Manifest{Module: "mailu", Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "data-mail", "type": "directory"}, + }} + dirs := dirsFor(m, Rendering{}) + if dirs["state"] != "/var/lib/mailu" { + t.Fatalf("place %q is the assignment's root; got %q", ".", dirs["state"]) + } + if dirs["data-mail"] != "/var/lib/mailu/data-mail" { + t.Fatalf("everything else sits beneath it; got %q", dirs["data-mail"]) + } + root := shallowCopy(m.Resources[0]) + if err := dirInto(root, dirs, m.Module); err != nil { + t.Fatal(err) + } + if root["path"] != "/var/lib/mailu" { + t.Fatalf("the root receives its path; got %v", root["path"]) + } + if _, still := root["place"]; still { + t.Fatal("place must never reach the host, which parses strictly") + } +} + +func TestTheManifestsMapsArePlaced(t *testing.T) { + m := Manifest{ + Module: "photos", + Resources: []map[string]any{ + {"id": "state", "type": "directory", "place": "."}, + }, + Binds: map[string]string{"route": "${dir:state}/route.json"}, + Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"}, + OwnSecrets: map[string]string{"admin-key": "${dir:state}/admin-key.secret"}, + Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"}, + } + placed, err := placedManifest(m, Rendering{}) + if err != nil { + t.Fatal(err) + } + if placed.Binds["route"] != "/var/lib/photos/route.json" { + t.Fatalf("binds are placed; got %v", placed.Binds) + } + if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" { + t.Fatalf("secrets are placed; got %v", placed.Secrets) + } + if placed.OwnSecrets["admin-key"] != "/var/lib/photos/admin-key.secret" { + t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets) + } + if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" { + t.Fatalf("receives are placed; got %v", placed.Receives) + } + if m.Binds["route"] != "${dir:state}/route.json" { + t.Fatalf("the manifest itself stays a template; got %v", m.Binds) + } +} + +func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { + m := Manifest{ + Module: "photos", + Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}}, + Binds: map[string]string{"route": "${dir:stat}/route.json"}, + } + problems := m.unknownDirRefs() + if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) { + t.Fatalf("a map naming no directory is a manifest problem; got %v", problems) + } +} + +func TestPlaceIsValidatedAtTheManifest(t *testing.T) { + both := Manifest{Module: "x", Resources: []map[string]any{ + {"id": "d", "type": "directory", "place": ".", "path": "/somewhere"}, + }} + if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") { + t.Fatalf("path beside place refuses; got %v", got) + } + elsewhere := Manifest{Module: "x", Resources: []map[string]any{ + {"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""}, + }} + if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") { + t.Fatalf("place on a file refuses; got %v", got) + } + wrong := Manifest{Module: "x", Resources: []map[string]any{ + {"id": "d", "type": "directory", "place": "sub/dir"}, + }} + if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the only place is "."`) { + t.Fatalf("a place that is not the root refuses; got %v", got) + } +} + func shallowCopy(resource map[string]any) map[string]any { copied := map[string]any{} for k, v := range resource { diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index cf56977..e73da8b 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1049,9 +1049,9 @@ func ParseManifest(raw []byte) (Manifest, error) { } } for to, where := range m.Binds { - if !strings.HasPrefix(where, "/") { + if !placedOrAbsolute(where) { problems = append(problems, fmt.Sprintf( - "%s binds %q at %q, which is not an absolute path", m.Module, to, where)) + "%s binds %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) } var wanted bool for _, w := range m.Wants() { @@ -1183,9 +1183,9 @@ func ParseManifest(raw []byte) (Manifest, error) { } } for name, where := range m.OwnSecrets { - if !strings.HasPrefix(where, "/") { + if !placedOrAbsolute(where) { problems = append(problems, fmt.Sprintf( - "%s needs %q at %q, which is not an absolute path", m.Module, name, where)) + "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, where)) } if name == "" { problems = append(problems, m.Module+" needs a secret with no name") @@ -1200,9 +1200,9 @@ func ParseManifest(raw []byte) (Manifest, error) { } } for _, f := range m.SecretFiles(to) { - if !strings.HasPrefix(f.Path, "/") { + if !placedOrAbsolute(f.Path) { problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q at %q, which is not an absolute path", + "%s keeps the credential for %q at %q, which is neither an absolute path nor a placed one", m.Module, SecretLocal(to, f.Local), f.Path)) } if f.Local != "" && !name.MatchString(f.Local) { @@ -1252,9 +1252,9 @@ func ParseManifest(raw []byte) (Manifest, error) { } } for to, where := range m.Grants { - if !strings.HasPrefix(where, "/") { + if !placedOrAbsolute(where) { problems = append(problems, fmt.Sprintf( - "%s grants %q into %q, which is not an absolute path", m.Module, to, where)) + "%s grants %q into %q, which is neither an absolute path nor a placed one", m.Module, to, where)) } var offered bool for _, o := range m.Offers() { @@ -1275,9 +1275,9 @@ func ParseManifest(raw []byte) (Manifest, error) { if !name.MatchString(to) { problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to)) } - if !strings.HasPrefix(where, "/") { + if !placedOrAbsolute(where) { problems = append(problems, fmt.Sprintf( - "%s receives %q at %q, which is not an absolute path", m.Module, to, where)) + "%s receives %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) } var offered bool for _, o := range m.Offers() { -- 2.54.0