From 7d46e48b26a6fa09fc379a15803d2eaa40b98ef7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 04:03:50 +0200 Subject: [PATCH 1/2] The account's environment and the login shell are the mesh's seats (hq ADR 0203, 0204) node-environment says which module writes the account's environment; node-login-shell replaces the module-declared login-shell, so a second shell claims it rather than declaring a rival, and execute is the mesh's contract. login-shell is refused as a module's seat name. Seeded into a live store by the existing additive seeding. --- internal/catalogue/seats.go | 25 +++++++++++++++++++++++++ internal/catalogue/seats_declared.go | 13 +++++++++++++ internal/catalogue/seats_test.go | 9 +++++---- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 536f9ec..cec63fc 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -150,6 +150,17 @@ var defaultSeats = []Seat{ // served by the node tools runtime (ADR 0175). {Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177", Serves: serviceManagerVerbs()}, + // The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and + // every module contributes to it. No verbs — the seat says who places the environment's files, + // and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing + // which module wrote it. + {Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"}, + // The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176): + // the mesh's, so a second shell module claims the seat rather than declaring a second one, and + // the seat exists whether or not zsh's definition is registered. `execute` is the contract any + // node may call; the holder places every module's shell code in its slots. + {Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204", + Serves: loginShellVerbs()}, // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client // model change, not a rename, so it stays until that is built. {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, @@ -456,3 +467,17 @@ func serviceManagerVerbs() []Verb { Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})}, } } + +// loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR +// 0204): one command, run the way the operator's own terminal would run it, bounded below the +// runtime's thirty-second call limit so a hung command answers rather than times the caller out. +func loginShellVerbs() []Verb { + return []Verb{ + {Name: "execute", Description: "Run one command on this machine as the operator account, in a " + + "non-interactive login shell in its home; answers with what it printed and how it exited.", + Input: schema(map[string]string{ + "command": "the command line, as you would type it", + "timeout_seconds": "give up after this long, at most 25 (default 20)", + }, []string{"command"})}, + } +} diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 4afb221..5fb89bd 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -25,6 +25,10 @@ import ( // and nothing to keep in step when a mesh seat is added. const meshSeatPrefix = "mesh-" +// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's, +// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204). +const retiredLoginShell = "login-shell" + // A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says, // and what it answers. A caller declares that it uses the *seat*, never the module, so the // implementation can be replaced under it. @@ -88,6 +92,15 @@ func declaredSeatProblems(m Manifest) []string { "seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*")) continue } + if s.Name == retiredLoginShell { + // The name ADR 0176 gave the login shell when the zsh module declared it. The seat is + // the mesh's now, so a module declaring the old name would be a second login shell + // beside it, with a protocol of its own (novox/hq ADR 0204). + problems = append(problems, fmt.Sprintf( + "%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+ + "module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat)) + continue + } if seen[s.Name] { problems = append(problems, fmt.Sprintf( "%s declares the seat %q twice", m.Module, s.Name)) diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 22f3db7..31d8baa 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,10 +44,11 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired - // mesh-build-machine row goes, when no registered manifest claims it any more. - if len(Seats()) != 17 { - t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+ + // Nineteen since node-environment and node-login-shell (novox/hq ADR 0203, ADR 0204), after + // node-build-agent made seventeen (ADR 0190) — eighteen once the retired mesh-build-machine row + // goes, when no registered manifest claims it any more. + if len(Seats()) != 19 { + t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } From f19a2254ac352c8a2cb2cf81412156c833993654 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 04:03:50 +0200 Subject: [PATCH 2/2] Compose the account's environment and the shell's code from every module (hq ADR 0203, 0204) A module contributes environment variables, PATH entries and shell code in named slots; the holder of the matching seat places them with ${environment:posix|systemd} and ${shell::}. Rendered in module order with a naming line per contribution, PATH entries added only when missing, machine facts resolved first. A variable two modules set, or a placeholder outside its seat's holder, is refused at parse (the catalogue check) and at composition. Filled after every other placeholder pass, so no scanner ever reads a shell's own ${...}. --- internal/catalogue/declaration.go | 14 + internal/catalogue/environment_into.go | 523 ++++++++++++++++++++ internal/catalogue/environment_into_test.go | 471 ++++++++++++++++++ internal/catalogue/manifest.go | 17 + 4 files changed, 1025 insertions(+) create mode 100644 internal/catalogue/environment_into.go create mode 100644 internal/catalogue/environment_into_test.go diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index b2f651b..6bc1ce2 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -415,6 +415,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string, filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation, with.OutwardLinks, with.TunnelInterface) + // **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 + } + var out []map[string]any for _, m := range r.Modules { if with.Adopted && m.Filtering != nil { @@ -889,6 +896,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string, return nil, err } publishedOn(copied, m.Module, 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. + if err := contributionsInto(copied, m, r.Modules, thisMachine); 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. diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go new file mode 100644 index 0000000..f5f0484 --- /dev/null +++ b/internal/catalogue/environment_into.go @@ -0,0 +1,523 @@ +package catalogue + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// The account's environment and the login shell's code, composed from the modules a node runs +// (novox/hq ADR 0203, ADR 0204). +// +// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH, +// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no +// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the +// result with a placeholder in its own file, and the controller fills it from every module on the +// node. A node not running a module has none of its contribution, and unassigning one takes its +// lines away at the next composition. +// +// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the +// controller writes in two standard formats — POSIX assignment and the service manager's +// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all +// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which +// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR +// 0204). + +// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules +// contributed: the account's environment, and the login shell's code. +const ( + EnvironmentSeat = "node-environment" + LoginShellSeat = "node-login-shell" +) + +// Where an environment entry on PATH goes: before the account's existing PATH, or after it. +const ( + PathAtStart = "start" + PathAtEnd = "end" +) + +// Environment is what one module adds to the account's environment (novox/hq ADR 0203). +type Environment struct { + // Variables are names and literal values. A value may name the machine's own facts with + // ${machine:…}, resolved before anything is written, and nothing else that expands. + Variables map[string]string `json:"variables,omitempty"` + // Path is entries on the account's PATH, each at its start or its end, in the order declared. + Path []PathEntry `json:"path,omitempty"` +} + +// PathEntry is one directory a module puts on the account's PATH. +type PathEntry struct { + Entry string `json:"entry"` + At string `json:"at"` +} + +// ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204). +type ShellCode struct { + // For is the shell the code is written in. + For string `json:"for"` + // Slot is where it runs among the other modules' code: first, normal or last. Named rather + // than numbered, because every contributor would guess a number and a collision says nothing. + Slot string `json:"slot"` + // Code is never interpreted — it is the shell's syntax, and only the shell reads it. + Code string `json:"code"` +} + +// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal +// at the check rather than code that silently lands in no placeholder. +var ( + knownShells = []string{"zsh", "bash", "fish"} + knownSlots = []string{"first", "normal", "last"} +) + +// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3). +const ( + EnvironmentPOSIX = "posix" + EnvironmentSystemd = "systemd" +) + +// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix}, +// ${environment:systemd} and ${shell::}. Loose inside the braces on purpose, so a +// misspelt key is found and refused rather than left in a file as a literal nobody reads. +var ( + ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`) + ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`) +) + +// variableName is a POSIX shell variable name, which is also what environment.d accepts. +var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// environmentProblems is what is wrong with this module's environment contribution, from the +// manifest alone. +func (m Manifest) environmentProblems() []string { + if m.Environment == nil { + return nil + } + var problems []string + for _, n := range sortedKeys(m.Environment.Variables) { + switch { + case !variableName.MatchString(n): + problems = append(problems, fmt.Sprintf( + "%s sets the variable %q, which is not a name a shell accepts: a letter or an "+ + "underscore, then letters, digits and underscores", m.Module, n)) + continue + case n == "PATH": + // PATH is the one variable every module shares, so no module may set it whole: a second + // setter would replace the first's entries, and the account's own PATH with them. + problems = append(problems, fmt.Sprintf( + "%s sets PATH as a variable; a module adds an entry under environment.path, at the "+ + "start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module)) + continue + } + if why := literalProblem(m.Environment.Variables[n]); why != "" { + problems = append(problems, fmt.Sprintf( + "%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule)) + } + } + seen := map[string]bool{} + for i, p := range m.Environment.Path { + switch { + case p.Entry == "": + problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1)) + case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"): + // A colon is PATH's own separator, so an entry holding one is two entries, and the + // check that it is already present would look for the wrong thing. + problems = append(problems, fmt.Sprintf( + "%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry)) + case seen[p.Entry]: + problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry)) + default: + if why := literalProblem(p.Entry); why != "" { + problems = append(problems, fmt.Sprintf( + "%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule)) + } + } + seen[p.Entry] = true + if p.At != PathAtStart && p.At != PathAtEnd { + problems = append(problems, fmt.Sprintf( + "%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH", + m.Module, p.Entry, p.At, PathAtStart, PathAtEnd)) + } + } + return problems +} + +// literalRule is why a value must be literal, said with every refusal of one. +const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " + + "names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)" + +// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in +// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is +// quoting in one and a character in the other; a line break ends the line in both. +func literalProblem(v string) string { + switch { + case strings.ContainsAny(v, `'"`): + return "holds a quote" + case strings.Contains(v, `\`): + return "holds a backslash" + case strings.ContainsAny(v, "\n\r"): + return "holds a line break" + case strings.ContainsRune(v, 0): + return "holds a NUL" + case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"): + return "holds a $ that is not one of the machine's ${machine:…} facts" + } + return "" +} + +// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code +// itself is not judged: it is the shell's syntax, which the controller does not read. +func (m Manifest) shellProblems() []string { + var problems []string + for i, c := range m.Shell { + if !oneOf(knownShells, c.For) { + problems = append(problems, fmt.Sprintf( + "%s's shell code %d is for %q; the shells are %s", m.Module, i+1, c.For, + strings.Join(knownShells, ", "))) + } + if !oneOf(knownSlots, c.Slot) { + problems = append(problems, fmt.Sprintf( + "%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot, + strings.Join(knownSlots, ", "))) + } + if strings.TrimSpace(c.Code) == "" { + problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1)) + } + } + return problems +} + +// contributionPlaceholderProblems is every place this module's resources name the environment or +// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before +// a mesh does, and again at composition in the same words. +func (m Manifest) contributionPlaceholderProblems() []string { + var problems []string + for _, r := range m.Resources { + problems = append(problems, placeholderProblems(m, r)...) + } + return problems +} + +// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}. +// +// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat +// authorises the bus's user list: a module that does not hold the account's environment writing it +// would be a second writer of a file there is one of, and a module that does not hold the login +// shell writing every module's shell code would be a second shell. +func placeholderProblems(m Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + s, ok := r[field].(string) + if !ok { + continue + } + env := ofEnvironment.FindAllStringSubmatch(s, -1) + code := ofShell.FindAllStringSubmatch(s, -1) + if len(env)+len(code) == 0 { + continue + } + if field != "content" { + // Placed only where a file's bytes are, which is where every one of them is meant to go: + // a path or an owner holding several lines of shell is nothing the host could act on. + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s in its %s; the environment and the shell's code are placed "+ + "only in a file's content", m.Module, r["id"], placeholderOf(env, code), field)) + continue + } + for _, e := range env { + if e[1] != EnvironmentPOSIX && e[1] != EnvironmentSystemd { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}", + m.Module, r["id"], e[0], EnvironmentPOSIX, EnvironmentSystemd)) + } + } + if len(env) > 0 && !m.ClaimsSeat(EnvironmentSeat) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; the account's environment is "+ + "written by that seat's holder alone (novox/hq ADR 0203)", + m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat)) + } + for _, c := range code { + shell, slot, two := strings.Cut(c[1], ":") + if !two || !oneOf(knownShells, shell) || !oneOf(knownSlots, slot) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s; shell code is ${shell::}, the shell one of "+ + "%s and the slot one of %s", m.Module, r["id"], c[0], + strings.Join(knownShells, ", "), strings.Join(knownSlots, ", "))) + } + } + if len(code) > 0 && !m.ClaimsSeat(LoginShellSeat) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's shell code is "+ + "placed by the login shell's holder alone (novox/hq ADR 0204)", + m.Module, r["id"], code[0][0], m.Module, LoginShellSeat)) + } + } + return problems +} + +func placeholderOf(env, code [][]string) string { + if len(env) > 0 { + return env[0][0] + } + return code[0][0] +} + +// contributedEnvironment is one node's environment, gathered and in the order it is written. +type contributedEnvironment struct { + // variables is by module in name order, each module's sorted by name. + variables []setBy + // start and end are PATH's entries in their final order, each once. + start, end []placedOn +} + +type setBy struct { + module string + names []string + values map[string]string +} + +type placedOn struct { + module, entry string +} + +// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq +// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in. +func inModuleOrder(modules []Manifest) []Manifest { + out := append([]Manifest(nil), modules...) + sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module }) + return out +} + +// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5), +// naming both. Neither is chosen: whichever was written last would win in one reader and not +// necessarily in the other, and the module that lost would not be told. +func variablesSetOnce(modules []Manifest) error { + setter := map[string]string{} + for _, m := range inModuleOrder(modules) { + if m.Environment == nil { + continue + } + for _, n := range sortedKeys(m.Environment.Variables) { + if first, taken := setter[n]; taken { + return fmt.Errorf( + "%s and %s both set %s on this machine; the account has one environment, so one "+ + "of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n) + } + setter[n] = m.Module + } + } + return nil +} + +// environmentOn gathers every module's environment on a node, with the machine's facts in place. +// +// A PATH entry two modules both add is written once, where the first puts it: two toolchains +// sharing ~/.local/bin is ordinary, and nothing about it is in conflict. +func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) { + var env contributedEnvironment + if err := variablesSetOnce(modules); err != nil { + return env, err + } + placed := map[string]bool{} + for _, m := range inModuleOrder(modules) { + if m.Environment == nil { + continue + } + if len(m.Environment.Variables) > 0 { + set := setBy{module: m.Module, values: map[string]string{}} + for _, n := range sortedKeys(m.Environment.Variables) { + v, err := factsIn(m.Environment.Variables[n], facts, m.Module, n) + if err != nil { + return env, err + } + set.names = append(set.names, n) + set.values[n] = v + } + env.variables = append(env.variables, set) + } + for _, p := range m.Environment.Path { + entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry") + if err != nil { + return env, err + } + if strings.Contains(entry, ":") { + return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator", + m.Module, entry) + } + if placed[entry] { + continue + } + placed[entry] = true + if p.At == PathAtEnd { + env.end = append(env.end, placedOn{m.Module, entry}) + } else { + env.start = append(env.start, placedOn{m.Module, entry}) + } + } + } + return env, nil +} + +// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before +// either format is written, so both say the same thing (novox/hq ADR 0203). +func factsIn(v string, facts map[string]string, module, what string) (string, error) { + for _, key := range machineUsed(v) { + value, has := facts[key] + if !has { + return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s", + module, what, key, orNothing(namesOfFacts(facts))) + } + v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value) + } + // Judged again once filled: a fact is the mesh's, and still has to be a literal both readers + // take alike. + if why := literalProblem(v); why != "" { + return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule) + } + return v, nil +} + +// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable +// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login +// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it. +// +// The start entries are written last-first: each is put in front of PATH, so the last written ends +// up first, and the result reads in module order, then the order each module declared. +func (e contributedEnvironment) posix() string { + var b strings.Builder + for _, set := range e.variables { + fmt.Fprintf(&b, "# %s\n", set.module) + for _, n := range set.names { + fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n]) + } + } + named := "" + for i := len(e.start) - 1; i >= 0; i-- { + p := e.start[i] + if p.module != named { + fmt.Fprintf(&b, "# %s\n", p.module) + named = p.module + } + fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n", + p.entry, p.entry) + } + named = "" + for _, p := range e.end { + if p.module != named { + fmt.Fprintf(&b, "# %s\n", p.module) + named = p.module + } + fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n", + p.entry, p.entry) + } + if len(e.start)+len(e.end) > 0 { + b.WriteString("export PATH\n") + } + return b.String() +} + +// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR +// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read +// once per manager start, so it needs no guard against running twice; the account's existing PATH +// sits between the start and the end entries. +func (e contributedEnvironment) systemd() string { + var b strings.Builder + for _, set := range e.variables { + fmt.Fprintf(&b, "# %s\n", set.module) + for _, n := range set.names { + fmt.Fprintf(&b, "%s=%s\n", n, set.values[n]) + } + } + if len(e.start) > 0 { + fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start)) + } + if len(e.end) > 0 { + fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end)) + } + return b.String() +} + +// modulesOf names who contributed a line holding several modules' entries, in the order they appear. +func modulesOf(entries []placedOn) string { + var names []string + seen := map[string]bool{} + for _, p := range entries { + if !seen[p.module] { + seen[p.module] = true + names = append(names, p.module) + } + } + return strings.Join(names, ", ") +} + +func entriesOf(entries []placedOn) string { + out := make([]string, len(entries)) + for i, p := range entries { + out[i] = p.entry + } + return strings.Join(out, ":") +} + +// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module +// order, each module's pieces in the order it declared them, each preceded by a line naming the +// module, and empty when nothing is contributed. +func shellCode(modules []Manifest, shell, slot string) string { + var b strings.Builder + for _, m := range inModuleOrder(modules) { + named := false + for _, c := range m.Shell { + if c.For != shell || c.Slot != slot { + continue + } + if !named { + fmt.Fprintf(&b, "# %s\n", m.Module) + named = true + } + b.WriteString(c.Code) + if !strings.HasSuffix(c.Code, "\n") { + b.WriteString("\n") + } + } + } + return b.String() +} + +// contributionsInto fills a holder's file with the node's environment and its shell code. +// +// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text +// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered +// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these +// were in place would read the shell's expansions as the mesh's and refuse them, or fill a +// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment +// is filled before the shell's code is, and each is replaced in a single pass over what the holder +// wrote, so a contributed piece is never scanned again. +func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string) error { + if problems := placeholderProblems(m, resource); len(problems) > 0 { + return fmt.Errorf("%s", problems[0]) + } + content, ok := resource["content"].(string) + if !ok { + return nil + } + if ofEnvironment.MatchString(content) { + env, err := environmentOn(modules, facts) + if err != nil { + return err + } + content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string { + if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd { + return env.systemd() + } + return env.posix() + }) + } + if ofShell.MatchString(content) { + content = ofShell.ReplaceAllStringFunc(content, func(placeholder string) string { + shell, slot, _ := strings.Cut(ofShell.FindStringSubmatch(placeholder)[1], ":") + return shellCode(modules, shell, slot) + }) + } + resource["content"] = content + return nil +} diff --git a/internal/catalogue/environment_into_test.go b/internal/catalogue/environment_into_test.go new file mode 100644 index 0000000..614da36 --- /dev/null +++ b/internal/catalogue/environment_into_test.go @@ -0,0 +1,471 @@ +package catalogue + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module +// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder). + +// contributors is a fixed set of contributions, in no particular order: what the renderings are +// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the +// ordinary case of two modules sharing a directory, and is written once. +func contributors() []Manifest { + return []Manifest{ + {Module: "zsh", Environment: &Environment{ + Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"}, + Path: []PathEntry{ + {Entry: "${machine:account-home}/.local/bin", At: PathAtStart}, + {Entry: "${machine:account-home}/bin", At: PathAtStart}, + {Entry: "/opt/scripts", At: PathAtEnd}, + }, + }}, + {Module: "go-toolchain", Environment: &Environment{ + Variables: map[string]string{"GOPATH": "${machine:account-home}/go"}, + Path: []PathEntry{ + {Entry: "${machine:account-home}/go/bin", At: PathAtStart}, + {Entry: "/usr/local/go/bin", At: PathAtStart}, + {Entry: "${machine:account-home}/.local/bin", At: PathAtStart}, + }, + }}, + {Module: "agent", Environment: &Environment{ + Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"}, + Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}}, + }}, + // A module contributing nothing is in the set and writes nothing. + {Module: "postgres"}, + } +} + +var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"} + +// The final PATH this set composes, around whatever the account had: the start entries in module +// order and then declared order, the account's own, then the end entries. +const composedPOSIX = `# agent +export DISABLE_AUTOUPDATER='1' +# go-toolchain +export GOPATH='/home/op/go' +# zsh +export EDITOR='vim' +export XDG_CONFIG_HOME='/home/op/.config' +# zsh +case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac +# go-toolchain +case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac +case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac +# agent +case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac +# zsh +case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac +export PATH +` + +const composedSystemd = `# agent +DISABLE_AUTOUPDATER=1 +# go-toolchain +GOPATH=/home/op/go +# zsh +EDITOR=vim +XDG_CONFIG_HOME=/home/op/.config +# go-toolchain, zsh +PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH} +# agent, zsh +PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts +` + +func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) { + env, err := environmentOn(contributors(), operatorFacts) + if err != nil { + t.Fatal(err) + } + if got := env.posix(); got != composedPOSIX { + t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX) + } +} + +func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) { + env, err := environmentOn(contributors(), operatorFacts) + if err != nil { + t.Fatal(err) + } + if got := env.systemd(); got != composedSystemd { + t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd) + } +} + +// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file +// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim +// is about what a shell does with the file, not about what the file looks like. +func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh on this machine") + } + script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX + + `[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" + + `echo "$PATH"; echo "$GOPATH"` + out, err := exec.Command(sh, "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("sourcing twice: %v\n%s", err, out) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" + if lines[0] != want { + t.Fatalf("PATH is %s, not %s", lines[0], want) + } + if lines[1] != "/home/op/go" { + t.Fatalf("GOPATH was not exported: %q", lines[1]) + } + // And an entry the account already has stays where it is, and once. + out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != + "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" { + t.Fatalf("an entry already on PATH was added again or moved: %s", got) + } +} + +// The environment.d rendering, read by the service manager's own generator where this machine has +// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted. +func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) { + generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator" + if _, err := os.Stat(generator); err != nil { + t.Skip("no environment.d generator on this machine") + } + config := t.TempDir() + if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(composedSystemd), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(generator) + cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + config, "XDG_CONFIG_HOME=" + config} + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + want := "PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" + if !strings.Contains(string(out), want+"\n") || !strings.Contains(string(out), "GOPATH=/home/op/go\n") { + t.Fatalf("the service manager read\n%s", out) + } +} + +// Nothing contributed renders nothing, in both formats — not an empty `export PATH`. +func TestNoContributionsRenderNothing(t *testing.T) { + env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts) + if err != nil { + t.Fatal(err) + } + if env.posix() != "" || env.systemd() != "" { + t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd()) + } +} + +// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is. +func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) { + _, err := environmentOn(contributors(), map[string]string{"name": "server"}) + if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") || + !strings.Contains(err.Error(), "${machine:account-home}") { + t.Fatalf("a missing account home was not refused by name: %v", err) + } +} + +// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins. +func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) { + modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{ + Variables: map[string]string{"EDITOR": "nvim"}}}) + _, err := environmentOn(modules, operatorFacts) + if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+ + "environment, so one of them must stop setting it (novox/hq ADR 0203)" { + t.Fatalf("a variable set twice was not refused naming both: %v", err) + } + // And at composition, whether or not the node holds the environment. + r := Resolution{Node: "workstation", Account: "op", Modules: modules} + if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") { + t.Fatalf("composition accepted a variable set twice: %v", err) + } +} + +// shells contributes code for several shells and slots, in no order. +func shells() []Manifest { + return []Manifest{ + {Module: "zsh-syntax-highlighting", Shell: []ShellCode{ + {For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"}, + }}, + {Module: "powerlevel10k", Shell: []ShellCode{ + {For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" + + " source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"}, + {For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"}, + {For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"}, + }}, + {Module: "zsh-autosuggestions", Shell: []ShellCode{ + {For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"}, + {For: "bash", Slot: "normal", Code: "echo not for zsh"}, + }}, + {Module: "direnv", Shell: []ShellCode{ + {For: "fish", Slot: "last", Code: "direnv hook fish | source"}, + {For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""}, + }}, + } +} + +// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the +// order it declared them under a line naming it; empty when nothing is contributed. +func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) { + if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+ + "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+ + "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+ + "# zsh-autosuggestions\n"+ + "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want { + t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want) + } + if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+ + "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want { + t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want) + } + if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want { + t.Fatalf("bash's last slot is %q, not %q", got, want) + } + if got := shellCode(shells(), "fish", "first"); got != "" { + t.Fatalf("a slot nobody contributed to holds %q", got) + } +} + +// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the +// slots — which holds `${…}` of the shell's own that no mesh pass may touch either. +func zshHolder() Manifest { + return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}, + Resources: []map[string]any{ + {"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" + + "${shell:zsh:first}" + + "PROMPT='%n@%m ${PWD/#$HOME/~} '\n" + + "${shell:zsh:normal}" + + "alias ll='ls -l'\n" + + "${shell:zsh:last}"}, + }} +} + +// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for +// byte, because the shell's code is placed after every other placeholder pass and in one pass — a +// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and +// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see. +func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) { + modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{ + {For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"}, + }}) + r := Resolution{Node: "workstation", Account: "op", Modules: modules} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + var zshrc map[string]any + for _, res := range out { + if res["id"] == "zsh.zshrc" { + zshrc = res + } + } + if zshrc == nil { + t.Fatalf("the holder's file was not composed: %v", out) + } + if zshrc["path"] != "/home/op/.zshrc" { + t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"]) + } + want := "# powerlevel10k\n" + + "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" + + " source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" + + "PROMPT='%n@%m ${PWD/#$HOME/~} '\n" + + "# powerlevel10k\n" + + "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" + + "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" + + "# zsh-autosuggestions\n" + + "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" + + "alias ll='ls -l'\n" + + "# sly\n" + + "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" + + "# zsh-syntax-highlighting\n" + + "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n" + if got := zshrc["content"]; got != want { + t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want) + } +} + +// The holder of node-environment places both renderings, and they are the same as rendered alone. +func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) { + holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}}, + Resources: []map[string]any{ + {"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh", + "content": "# The mesh's environment.\n${environment:posix}"}, + {"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", + "content": "${environment:systemd}"}, + }} + r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + by := map[string]any{} + for _, res := range out { + by[res["id"].(string)] = res["content"] + } + if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX { + t.Fatalf("the POSIX file is\n%v", by["node-env.posix"]) + } + if by["node-env.systemd"] != composedSystemd { + t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"]) + } +} + +// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser, +// which is what the catalogue check and registration run, and again at composition, in the same words. +func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, + {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, + } { + raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err) + } + m := Manifest{Module: "toolchain", Resources: []map[string]any{ + {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} + r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}} + if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("composition accepted %s outside its holder: %v", c.content, err) + } + } +} + +// A key nobody renders is refused, not left in the file as a literal. +func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"}, + {"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell::}"}, + {"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell::}"}, + {"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell::}"}, + } { + raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` + + `"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s was accepted: %v", c.content, err) + } + } + // And outside a file's content, where nothing could be placed. + raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` + + `"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || + !strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") { + t.Errorf("a placeholder in a path was accepted: %v", err) + } +} + +// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else. +func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) { + for _, c := range []struct{ environment, want string }{ + {`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`}, + {`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`}, + {`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`}, + {`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`}, + {`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`}, + {`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`}, + {`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`}, + {`{"variables":{"A":"a\\b"}}`, `which holds a backslash`}, + {`{"variables":{"A":"a\nb"}}`, `which holds a line break`}, + {`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`}, + {`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`}, + {`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`}, + {`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`}, + {`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`}, + {`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`}, + {`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`}, + {`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`}, + } { + _, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.environment, c.want, err) + } + } + // What is allowed: a literal, and the machine's own facts. + if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` + + `"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` + + `"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil { + t.Fatalf("a well-formed environment was refused: %v", err) + } +} + +func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) { + for _, c := range []struct{ shell, want string }{ + {`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`}, + {`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`}, + {`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`}, + {`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`}, + } { + _, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.shell, c.want, err) + } + } + // The code itself is never judged: a shell's own `${…}` is not the mesh's. + if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` + + `"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil { + t.Fatalf("shell code was judged as if it were the mesh's: %v", err) + } +} + +// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login +// shell's contract is `execute`, described and with a schema an agent can call. +func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { + env, ok := SeatNamed("node-environment") + if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" || + len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" { + t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok) + } + shell, ok := SeatNamed("node-login-shell") + if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" { + t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok) + } + if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" { + t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves) + } + props, _ := shell.Serves[0].Input["properties"].(map[string]any) + required, _ := shell.Serves[0].Input["required"].([]string) + if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" { + t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input) + } + if _, has := props["timeout_seconds"]; !has { + t.Fatalf("execute takes no timeout: %v", props) + } +} + +// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's +// name nor under the name a module gave it before. +func TestNoModuleMayDeclareTheLoginShell(t *testing.T) { + for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} { + raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}` + _, err := ParseManifest([]byte(raw)) + if err == nil { + t.Errorf("a module declaring %q was accepted", n) + } + } + got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh", + DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ") + if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) { + t.Fatalf("declaring login-shell was not refused by name: %q", got) + } + // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. + m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) + if err != nil { + t.Fatal(err) + } + if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil { + t.Fatalf("a shell module claiming the seat cannot hold it: %v", err) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 0986efd..7d736de 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -517,6 +517,17 @@ type Manifest struct { // holder. Like Filtering: one module per node gathers what every module declared and writes it. Jailing *Jailing `json:"jailing,omitempty"` + // Environment is what this module adds to the operator account's environment: variables, and + // entries on PATH (novox/hq ADR 0203). Facts, not lines of one shell's syntax — the holder of + // node-environment places them, and the controller writes them in each reader's format. Like + // Jails: any module contributes, gathered from every module on the node, written by the holder. + Environment *Environment `json:"environment,omitempty"` + + // Shell is code this module adds to the login shell's startup, for a named shell in a named + // slot (novox/hq ADR 0204). The controller never reads it: it is placed, in module order, where + // the holder of node-login-shell put the slot's placeholder. + Shell []ShellCode `json:"shell,omitempty"` + // Guards are ports of this module's the mesh refuses on an adopted node except from the // private network and from the machine itself (novox/hq ADR 0100) — the store's port and the // broker's management port. The ports the software uses; the mesh guards where the machine @@ -1805,6 +1816,12 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, m.unknownDirRefs()...) problems = append(problems, m.unknownAccessRefs()...) problems = append(problems, m.jailProblems()...) + // What a module adds to the account's environment and to the login shell, and the holder's + // placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses + // them in the words registration does. + problems = append(problems, m.environmentProblems()...) + problems = append(problems, m.shellProblems()...) + problems = append(problems, m.contributionPlaceholderProblems()...) for i, r := range m.Resources { id, _ := r["id"].(string)