From a95c2b6ea9818b129268d9568b14ed8fa249a132 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:48:14 +0200 Subject: [PATCH] A unit may be user-scoped: applied through the account's own manager (hq ADR 0177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workstation's per-user daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in the operator account's own service manager, and until now had no form the mesh could send (to-be 29). The `service` shape gains `scope` ("system", the default, or "user") and `user` (the account, named ${machine:account} by a module); a user-scoped unit without an account, or a system unit naming one, is refused at parse. The host reaches the account's manager as `systemctl --user --machine=@` from its own process: no environment to forge, no user to switch to. Done on the runner rather than per system, since every system's reading of a unit already goes through systemctl. Apply, reflect-only and removal all go through the same manager, and the applied record carries scope and user so removal gives the unit back to the manager it came from. It answers only while that manager runs — a login, or lingering enabled for the account; declaring lingering is a follow-up. Tests: a user-scoped unit is started and enabled in the account's manager and recorded with its scope; a system unit never sees --user; the validation of scope and user. --- internal/apply/apply.go | 29 ++++++++ internal/apply/userscope_test.go | 99 ++++++++++++++++++++++++++ internal/declaration/declaration.go | 32 +++++++++ internal/declaration/userscope_test.go | 30 ++++++++ internal/store/store.go | 4 ++ 5 files changed, 194 insertions(+) create mode 100644 internal/apply/userscope_test.go create mode 100644 internal/declaration/userscope_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 2184946..debee7f 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -58,6 +58,8 @@ type Outcome struct { kept string // stateless is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117). stateless bool + // scope and user are, for a service, whose manager it was applied through (novox/hq ADR 0177). + scope, user string // found is, for a service, its unit as the host first found it (novox/hq ADR 0118). found *store.FoundUnit // reads is, for a container, the digest of each file it was created reading, by path — so @@ -563,6 +565,8 @@ func ApplyKeeping( Kept: kept, Reads: outcome.reads, Stateless: outcome.stateless, + Scope: outcome.scope, + User: outcome.user, Found: outcome.found, Holds: holds(resource), }) @@ -1043,10 +1047,12 @@ type unitReloader interface { func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner, changed map[string]bool, previous store.Applied) (Outcome, error) { + run = managerFor(r.Scope, r.User, run) if r.Stateless() { return reflectOnly(ctx, sys, r, run, changed) } out := begin(r) + out.scope, out.user = r.Scope, r.User var changes []string // A file the service reflects changed, and it may be the unit's own file or a drop-in: the @@ -1186,7 +1192,9 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service // a machine that uses another — and it reads the change when whatever starts it does. func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner, changed map[string]bool) (Outcome, error) { + run = managerFor(r.Scope, r.User, run) out := begin(r) + out.scope, out.user = r.Scope, r.User out.stateless = true restart := reflected(r, changed) reload := restartedBy(r.ReloadOn, changed) @@ -2235,6 +2243,7 @@ func declaredDigest(r declaration.Resource) string { // what was actually done — a unit already as it was found is forgotten, not "restored". func removeService(ctx context.Context, sys system.System, a store.Applied, run Runner, made bool) (string, string, error) { + run = managerFor(a.Scope, a.User, run) if a.Stateless { // Declared with no state (novox/hq ADR 0117): its lifecycle was never the mesh's, and the // declaration that said so is the operator's word to hold to, a file of the mesh's or not. @@ -2409,3 +2418,23 @@ func moduleOf(identity string) (string, bool) { } return identity[:at], true } + +// managerFor routes a unit's commands to the manager it belongs to (novox/hq ADR 0177). A system +// unit's go to the machine's manager as they always did. A user-scoped unit's go to the account's +// own: `systemctl --user --machine=@`, which reaches that manager from the host's own +// process without an environment to forge or a user to switch to — and which only answers while +// the account's manager runs (a login, or lingering enabled for the account). Done on the runner +// rather than in each system: every system's reading of a unit already goes through `systemctl`, +// so this is one place instead of one per system and one per method. +func managerFor(scope, user string, run Runner) Runner { + if scope != declaration.ScopeUser || user == "" { + return run + } + return func(ctx context.Context, name string, args ...string) (string, error) { + if name != "systemctl" { + return run(ctx, name, args...) + } + scoped := append([]string{"--user", "--machine=" + user + "@"}, args...) + return run(ctx, name, scoped...) + } +} diff --git a/internal/apply/userscope_test.go b/internal/apply/userscope_test.go new file mode 100644 index 0000000..9e7ddf8 --- /dev/null +++ b/internal/apply/userscope_test.go @@ -0,0 +1,99 @@ +package apply + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0177: a unit in the operator account's own service manager is applied +// through that manager — `systemctl --user --machine=@` — and never as a system unit of +// the same name; its record remembers the scope so removal goes the same way. + +// accountManager is a user's service manager whose one unit starts when asked, recording the +// commands and refusing any that reach it outside the account's scope. +func accountManager(account string, commands *[]string) Runner { + active, enabled := false, false + return func(_ context.Context, name string, args ...string) (string, error) { + line := name + " " + strings.Join(args, " ") + *commands = append(*commands, line) + if name == "systemctl" && !strings.HasPrefix(line, "systemctl --user --machine="+account+"@ ") { + return "", fmt.Errorf("a system-scope command reached the account's manager: %s", line) + } + switch { + case strings.Contains(line, " start "): + active = true + return "", nil + case strings.Contains(line, " stop "): + active = false + return "", nil + case strings.Contains(line, " enable "): + enabled = true + return "", nil + case strings.Contains(line, " disable "): + enabled = false + return "", nil + case strings.Contains(line, "is-enabled"): + if enabled { + return "enabled", nil + } + return "disabled", nil + case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"): + if active { + return "LoadState=loaded\nActiveState=active\nSubState=running", nil + } + return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil + } + return "", nil + } +} + +func TestAUserScopedUnitIsAppliedThroughTheAccountsManager(t *testing.T) { + var commands []string + decl := `{"declaration":1,"resources":[ + {"id":"i3.watcher","type":"service","unit":"i3-reload-watcher.service","state":"running","boot":"enabled","scope":"user","user":"ops"} + ]}` + report, known, err := Apply(context.Background(), archHost(t), parse(t, decl), + store.State{}, store.OriginDeclared, accountManager("ops", &commands), nil, nil) + if err != nil { + t.Fatalf("apply: %v\n%s", err, strings.Join(commands, "\n")) + } + if !report.Changed() { + t.Fatal("a unit that was stopped and is now running changed nothing") + } + var started, enabled bool + for _, c := range commands { + if c == "systemctl --user --machine=ops@ start i3-reload-watcher.service" { + started = true + } + if c == "systemctl --user --machine=ops@ enable i3-reload-watcher.service" { + enabled = true + } + } + if !started || !enabled { + t.Fatalf("the unit was not started and enabled in the account's manager:\n%s", strings.Join(commands, "\n")) + } + recorded, ok := known.At("service", "i3-reload-watcher.service") + if !ok || recorded.Scope != "user" || recorded.User != "ops" { + t.Fatalf("the record does not say whose manager the unit is in: %+v", recorded) + } +} + +func TestASystemUnitIsUntouchedByTheScope(t *testing.T) { + var commands []string + decl := `{"declaration":1,"resources":[ + {"id":"x.daemon","type":"service","unit":"sshd.service","state":"running","boot":"enabled"} + ]}` + if _, _, err := Apply(context.Background(), archHost(t), parse(t, decl), + store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil); err != nil { + t.Fatal(err) + } + for _, c := range commands { + if strings.Contains(c, "--user") || strings.Contains(c, "--machine") { + t.Fatalf("a system unit was addressed to an account's manager: %s", c) + } + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 0d611b0..4246253 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -684,6 +684,16 @@ type Service struct { // declaration that reports success and stops being true at the next power cut. Boot string `json:"boot,omitempty"` + // Scope is whose service manager the unit belongs to: "system" (absent means system), or + // "user" — the operator account's own manager (novox/hq ADR 0177). A workstation's per-user + // daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in + // that manager, and until this they had no form the mesh could send. A user-scoped unit names + // its User; the host talks to that account's manager and never starts a system unit by the + // same name. + Scope string `json:"scope,omitempty"` + // User is the account whose manager a user-scoped unit lives in. Required with scope "user", + // refused otherwise; a module names it ${machine:account} and never the person. + User string `json:"user,omitempty"` // RestartOn names resources whose change means this service must be restarted. // // Because a running service does not re-read its configuration. Replace the file, find the @@ -729,11 +739,33 @@ func (s *Service) Identity() string { return s.ID } func (s *Service) Kind() Type { return TypeService } func (s *Service) Target() string { return s.Unit } +// ScopeSystem and ScopeUser are the two managers a unit may belong to (novox/hq ADR 0177). +const ( + ScopeSystem = "system" + ScopeUser = "user" +) + +// UserScoped is whether this unit lives in an account's own service manager. +func (s *Service) UserScoped() bool { return s.Scope == ScopeUser } + func (s *Service) validate(where string, _ bool) []string { var problems []string if s.Unit == "" { problems = append(problems, where+": a service needs a unit") } + switch s.Scope { + case "", ScopeSystem: + if s.User != "" { + problems = append(problems, where+": a system unit names no user; only a user-scoped unit does") + } + case ScopeUser: + if s.User == "" { + problems = append(problems, where+": a user-scoped unit names the account whose manager it lives in") + } + default: + problems = append(problems, fmt.Sprintf( + "%s: scope %q; a unit is in the \"system\" manager or the operator account's \"user\" one", where, s.Scope)) + } switch { case s.State == "running" || s.State == "stopped": case s.State != "": diff --git a/internal/declaration/userscope_test.go b/internal/declaration/userscope_test.go new file mode 100644 index 0000000..cee1940 --- /dev/null +++ b/internal/declaration/userscope_test.go @@ -0,0 +1,30 @@ +package declaration + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names +// the account, a system one may not, and any other word is refused. +func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) { + cases := []struct{ scope, user, wants string }{ + {"user", "ops", ""}, + {"", "", ""}, + {"system", "", ""}, + {"user", "", "names the account"}, + {"", "ops", "names no user"}, + {"session", "ops", "scope \"session\""}, + } + for _, c := range cases { + s := &Service{ID: "m.u", Type: TypeService, Unit: "u.service", State: "running", Scope: c.scope, User: c.user} + problems := s.validate("m.u", false) + got := strings.Join(problems, "; ") + if c.wants == "" && len(problems) != 0 { + t.Fatalf("scope %q user %q refused: %s", c.scope, c.user, got) + } + if c.wants != "" && !strings.Contains(got, c.wants) { + t.Fatalf("scope %q user %q: wanted a refusal saying %q, got %q", c.scope, c.user, c.wants, got) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 55efedf..054ce83 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -82,6 +82,10 @@ type Applied struct { // 0117) — kept here because removal happens once the declaration that said so is gone, and a // service removed as if it had a state is stopped: the machine's network manager, for one. Stateless bool `json:"stateless,omitempty"` + // Scope and User are, for a service in an account's own manager (novox/hq ADR 0177), which + // manager — so removal gives the unit back through the same one it was applied through. + Scope string `json:"scope,omitempty"` + User string `json:"user,omitempty"` // Found is, for a service, the state its unit was in when this host first applied it — before // the mesh started, stopped, enabled or disabled anything. Removal gives that back and nothing