From 5eb1c9c2b7046e33397387ff80ba1f8fb8e43f2c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:48:16 +0200 Subject: [PATCH 1/2] The service manager is a node seat with the unit verbs, and a user shape names the account (hq ADR 0176, 0177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-service-manager joins the mesh's own seats, node-scoped, serving eight verbs — units, status, start, stop, restart, enable, disable, journal — each with a schema that takes an optional scope, "system" or the operator account's "user" manager. Sixteen seats now; the count test says so and names the record. ${machine:account} resolves in a resource's `name` and `user` as it already did in path, owner and content: the shell module makes the operator's account its holder's login shell through the `user` shape, and the desktop's watchers run as that account through a user-scoped unit (host change alongside). Neither can name the person. A machine with no account refuses by name. --- internal/catalogue/machine_into_files.go | 7 ++- internal/catalogue/operators_machine_test.go | 60 ++++++++++++++++++++ internal/catalogue/seats.go | 39 +++++++++++++ internal/catalogue/seats_test.go | 5 +- 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 internal/catalogue/operators_machine_test.go diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index bc2071a..aa79e71 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -102,8 +102,11 @@ func accountHomeOf(account, home string) string { func machineInto(resource map[string]any, facts map[string]string, module string) error { // Content, and now the path and owner too: a module that writes into a person's home names it // with ${machine:account-home} and ${machine:account}, which it cannot know until assigned - // (novox/hq to-be 29), the same reason its content names ${machine:address}. - for _, field := range []string{"path", "owner", "content"} { + // (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a + // `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as: + // the shell module makes the operator's account its holder's login shell, and the desktop's + // watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. + for _, field := range []string{"path", "owner", "content", "name", "user"} { s, ok := resource[field].(string) if !ok { continue diff --git a/internal/catalogue/operators_machine_test.go b/internal/catalogue/operators_machine_test.go new file mode 100644 index 0000000..ddac43d --- /dev/null +++ b/internal/catalogue/operators_machine_test.go @@ -0,0 +1,60 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A `user` shape and a user-scoped unit name the operator account the way a home file does +// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned. +func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) { + facts := map[string]string{"account": "ops", "account-home": "/home/ops"} + login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"} + if err := machineInto(login, facts, "zsh"); err != nil { + t.Fatal(err) + } + if login["name"] != "ops" { + t.Fatalf("the user shape did not learn the account: %v", login["name"]) + } + watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service", + "scope": "user", "user": "${machine:account}"} + if err := machineInto(watcher, facts, "i3"); err != nil { + t.Fatal(err) + } + if watcher["user"] != "ops" { + t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"]) + } + // A machine with no operator account refuses rather than writing the literal. + err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"}, + map[string]string{"address": "10.0.0.1"}, "zsh") + if err == nil || !strings.Contains(err.Error(), "${machine:account}") { + t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err) + } +} + +// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq +// ADR 0177): every verb described, with a schema, taking a scope. +func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) { + seat, ok := SeatNamed("node-service-manager") + if !ok { + t.Fatal("node-service-manager is not a seat the mesh defines") + } + if seat.Scope != ScopeNode { + t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope) + } + want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"} + var got []string + for _, v := range seat.Serves { + got = append(got, v.Name) + if v.Description == "" || v.Input == nil { + t.Fatalf("%s is promised without a description or a schema", v.Name) + } + props, _ := v.Input["properties"].(map[string]any) + if _, has := props["scope"]; !has { + t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name) + } + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("the seat serves %v, not %v", got, want) + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 3121db2..19893a7 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -119,6 +119,12 @@ var defaultSeats = []Seat{ "active found firewall's chains. An operator's act, by name, never a flush.", Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})}, }}, + // The machine's service manager (novox/hq ADR 0177). The host applies every declared unit, + // system or user scope; the holder answers questions and operator acts about them, each verb + // taking the unit and an optional scope. The holder runs nothing of its own: its verbs are + // served by the node tools runtime (ADR 0175). + {Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177", + Serves: serviceManagerVerbs()}, // 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"}, @@ -392,3 +398,36 @@ func SeatsWithAProtocol() []Seat { } return out } + +// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR +// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an +// optional scope — "system" when absent, "user" for the operator account's own manager — so a +// caller asks for a user unit the way it asks for a system one. +func serviceManagerVerbs() []Verb { + scoped := func(more map[string]string, required []string) map[string]any { + props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"} + for k, v := range more { + props[k] = v + } + return schema(props, required) + } + unit := map[string]string{"unit": "the unit's name, as the service manager knows it"} + return []Verb{ + {Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", + Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)}, + {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", + Input: scoped(unit, []string{"unit"})}, + {Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", + Input: scoped(unit, []string{"unit"})}, + {Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", + Input: scoped(unit, []string{"unit"})}, + {Name: "restart", Description: "Restart one unit.", + Input: scoped(unit, []string{"unit"})}, + {Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).", + Input: scoped(unit, []string{"unit"})}, + {Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).", + Input: scoped(unit, []string{"unit"})}, + {Name: "journal", Description: "The last lines of one unit's journal.", + Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})}, + } +} diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 128b01c..a3e7047 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,8 +44,9 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - if len(Seats()) != 15 { - t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ + // Sixteen since node-service-manager (novox/hq ADR 0177). + if len(Seats()) != 16 { + t.Errorf("the mesh defines %d seats rather than 16; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } -- 2.54.0 From a9307d9f33be154d08aff3e0e5d98a8e30f57000 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:53:27 +0200 Subject: [PATCH 2/2] The controller seat gains a generic verb: command runs one line of the binary and answers what it printed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beside the named verbs, `command` takes a command line as the controller's own shell would — `node account g14 jochen`, `node show ace`, `module list` — splits it as a shell does (quotes group, backslash escapes, nothing expanded) and runs it in this binary like every other verb. The named verbs keep their schemas; this is the whole binary, added because the operator decided any node may call any tool (hq ADR 0175) and a verb per command was the only thing keeping the rest behind a shell on the control node. ADR 0154 carries the dated note. Tests: a plain line, quoted words, an empty line and an unclosed quote refused. --- cmd/mesh-controller/seatverbs.go | 69 +++++++++++++++++++++++++++ cmd/mesh-controller/seatverbs_test.go | 24 ++++++++++ internal/catalogue/verbs.go | 7 +++ 3 files changed, 100 insertions(+) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 51640a5..a7d99ef 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -48,6 +48,21 @@ func argvFor(verb string, args map[string]any) ([]string, error) { return nil } switch verb { + case "command": + // The generic verb: the command line as given, split as a shell would split it, with + // nothing added — the named verbs add flags a caller cannot reach; this one is the whole + // binary and says so in its description (novox/hq ADR 0154, 0175). + if err := need("command"); err != nil { + return nil, err + } + argv, err := splitCommandLine(str("command")) + if err != nil { + return nil, err + } + if len(argv) == 0 { + return nil, errors.New("command names no command") + } + return argv, nil case "status": return []string{"status", "--json"}, nil case "nodes": @@ -289,3 +304,57 @@ func sampleArguments(v catalogue.Verb) map[string]any { } return sample } + +// splitCommandLine splits a command line into words the way a POSIX shell does for the simple +// cases a controller command needs: spaces separate, single or double quotes group, a backslash +// escapes the next character inside double quotes or outside any. No expansion of anything. +func splitCommandLine(line string) ([]string, error) { + var words []string + var cur strings.Builder + inWord := false + quote := rune(0) + runes := []rune(line) + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case quote == '\'': + if r == '\'' { + quote = 0 + } else { + cur.WriteRune(r) + } + case quote == '"': + if r == '"' { + quote = 0 + } else if r == '\\' && i+1 < len(runes) { + i++ + cur.WriteRune(runes[i]) + } else { + cur.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + inWord = true + case r == '\\' && i+1 < len(runes): + i++ + cur.WriteRune(runes[i]) + inWord = true + case r == ' ' || r == '\t' || r == '\n': + if inWord { + words = append(words, cur.String()) + cur.Reset() + inWord = false + } + default: + cur.WriteRune(r) + inWord = true + } + } + if quote != 0 { + return nil, fmt.Errorf("command has an unclosed %c quote", quote) + } + if inWord { + words = append(words, cur.String()) + } + return words, nil +} diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 9bbc6b7..a072ec1 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -171,3 +171,27 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) { t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output) } } + +// `command` is the generic verb: the command line as given, split as a shell would, nothing added — +// so an operator's `node account g14 jochen` is one call through the console rather than a shell on +// the control node (novox/hq ADR 0154, ADR 0175). +func TestCommandRunsTheLineAsGiven(t *testing.T) { + argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"}) + if err != nil || strings.Join(argv, " ") != "node account g14 jochen" { + t.Fatalf("a plain line: %v %v", argv, err) + } + argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`}) + if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { + t.Fatalf("a quoted word stays one word: %q %v", argv, err) + } + argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`}) + if err != nil || len(argv) != 4 || argv[2] != "the box" { + t.Fatalf("double quotes group: %q %v", argv, err) + } + if _, err := argvFor("command", map[string]any{"command": " "}); err == nil { + t.Fatal("an empty line was accepted") + } + if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil { + t.Fatal("an unclosed quote was accepted") + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 61d77cb..f8f52bd 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -145,6 +145,13 @@ var ControllerVerbs = []Verb{ "node": "one machine; the whole mesh when absent", "clear": "\"true\" to remove the layer instead of setting it", }, []string{"module"})}, + {Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + + "shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " + + "generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " + + "per command. Any node may call any tool (ADR 0175), so nothing is held back here.", + Input: schema(map[string]string{ + "command": "the command line, as the controller's binary takes it; quotes group a word with spaces", + }, []string{"command"})}, {Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " + "`builds` with that id follows it line by line, and the module is registered when the outcome comes.", Input: schema(map[string]string{ -- 2.54.0