From 5eb1c9c2b7046e33397387ff80ba1f8fb8e43f2c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:48:16 +0200 Subject: [PATCH] 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()) } }