Slice two of ADR 0112. A pathless directory saying place "." is the
assignment's one directory, <root>/<module> — 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.
323 lines
10 KiB
Go
323 lines
10 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// A directory the mesh places (novox/hq ADR 0112, to-be 27, issue 119).
|
|
//
|
|
// **A module definition names no host path.** A directory resource may omit `path`; the mesh
|
|
// resolves where it lands when the declaration is composed — `<root>/<module>/<id>`, the root a
|
|
// node's own setting with /var/lib as the default. From then on the module's own files, mounts
|
|
// and environment name the place as `${dir:<id>}`, the same shape as `${bound:…}` and
|
|
// `${secret:…}`: a fact the module asks for by name and never states.
|
|
//
|
|
// **A directory that states a path keeps it, and still answers `${dir:<id>}`.** That is the
|
|
// placement for an adopted machine: data that must sit where the predecessor already put it is
|
|
// declared with the path as the exception it is, and everything else in the module names it by
|
|
// id — so moving it later is one line, not a search.
|
|
//
|
|
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
|
|
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
|
|
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
|
|
|
|
// defaultDataRoot is where module data lands when a node states no root of its own.
|
|
const defaultDataRoot = "/var/lib"
|
|
|
|
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
|
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
|
|
|
// dataRoot is the root this node keeps placed directories under.
|
|
func dataRoot(with Rendering) string {
|
|
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
|
|
return root
|
|
}
|
|
return defaultDataRoot
|
|
}
|
|
|
|
// 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, <root>/<module> —
|
|
// 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 {
|
|
if fmt.Sprint(r["type"]) != "directory" {
|
|
continue
|
|
}
|
|
id := fmt.Sprint(r["id"])
|
|
if path, stated := r["path"].(string); stated && path != "" {
|
|
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.
|
|
//
|
|
// A reference naming no directory of this module is refused. Left as written, the literal
|
|
// `${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) { 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
|
|
if path, ok := resource["path"].(string); ok {
|
|
if resource["path"], err = fill(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if content, ok := resource["content"].(string); ok {
|
|
if resource["content"], err = fill(content); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Nested values are rebuilt, never written into: the resource is a shallow copy of the
|
|
// manifest's own map, and the manifest is composed once per node — a fill written in place
|
|
// would leave the first node's paths inside every later composition.
|
|
if volumes, ok := resource["volumes"].([]any); ok {
|
|
filled := make([]any, len(volumes))
|
|
for i, v := range volumes {
|
|
filled[i] = v
|
|
if mount, ok := v.(string); ok {
|
|
if filled[i], err = fill(mount); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["volumes"] = filled
|
|
}
|
|
if env, ok := resource["env"].(map[string]any); ok {
|
|
filled := make(map[string]any, len(env))
|
|
for key, v := range env {
|
|
filled[key] = v
|
|
if value, ok := v.(string); ok {
|
|
if filled[key], err = fill(value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["env"] = filled
|
|
}
|
|
if files, ok := resource["env-file"].([]any); ok {
|
|
filled := make([]any, len(files))
|
|
for i, v := range files {
|
|
filled[i] = v
|
|
if path, ok := v.(string); ok {
|
|
if filled[i], err = fill(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["env-file"] = filled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition
|
|
// declares — refused where the author is, not at composition on some later day (the same
|
|
// near-versus-far reasoning as the host's strict parse).
|
|
func (m Manifest) unknownDirRefs() []string {
|
|
declared := map[string]bool{}
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) == "directory" {
|
|
declared[fmt.Sprint(r["id"])] = true
|
|
}
|
|
}
|
|
referenced := func(s string) []string {
|
|
var ids []string
|
|
for _, match := range dirRef.FindAllStringSubmatch(s, -1) {
|
|
ids = append(ids, match[1])
|
|
}
|
|
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] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+
|
|
"cannot place would reach the machine as a literal path",
|
|
m.Module, id, where, id))
|
|
}
|
|
for _, r := range m.Resources {
|
|
for _, field := range []string{"path", "content"} {
|
|
if s, ok := r[field].(string); ok {
|
|
for _, id := range referenced(s) {
|
|
refuse(id, r["id"])
|
|
}
|
|
}
|
|
}
|
|
for _, field := range []string{"volumes", "env-file"} {
|
|
if list, ok := r[field].([]any); ok {
|
|
for _, v := range list {
|
|
if s, ok := v.(string); ok {
|
|
for _, id := range referenced(s) {
|
|
refuse(id, r["id"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if env, ok := r["env"].(map[string]any); ok {
|
|
for _, v := range env {
|
|
if s, ok := v.(string); ok {
|
|
for _, id := range referenced(s) {
|
|
refuse(id, r["id"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
func namesOfDirs(dirs map[string]string) []string {
|
|
var names []string
|
|
for id := range dirs {
|
|
names = append(names, fmt.Sprintf("%q", id))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|