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