diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 65c620a..16e7266 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1487,6 +1487,11 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D if change.Action == "held" { continue } + // Nor is a unit waiting for its account's manager that this machine never applied (novox/hq + // ADR 0177); one applied before and waiting now is still the machine's, recorded as it was. + if _, recorded := updated.Find(change.ID); change.Action == "waiting" && !recorded { + continue + } report.Applied = append(report.Applied, change.ID) } // Kept whichever way it went, so a node that is disconnected next minute still knows what it diff --git a/internal/apply/apply.go b/internal/apply/apply.go index e217253..bf34d68 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -58,11 +58,16 @@ 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 // shell is, for a user, the login shell it was found with and the one the mesh set (novox/hq // ADR 0176 §2, issue 228). shell *store.LoginShell + // linger is, for a user, whether it lingered before the mesh changed that, and what the mesh + // set (novox/hq ADR 0177). + linger *store.Lingering // unpacked is, for an archive, what it put on the machine (novox/hq issue 162). unpacked *store.Unpacked // reads is, for a container, the digest of each file it was created reading, by path — so @@ -86,8 +91,9 @@ type Report struct { // nothing is the ordinary steady state, and saying so is not the same as saying it failed. func (r Report) Changed() bool { for _, o := range r.Outcomes { - // Holding is keeping the machine as it was found, which is not moving it. - if o.Action != "unchanged" && o.Action != "held" { + // Holding is keeping the machine as it was found, which is not moving it; waiting is a unit + // whose account's manager is not running, which nothing moved either (novox/hq ADR 0177). + if o.Action != "unchanged" && o.Action != "held" && o.Action != "waiting" { return true } } @@ -226,6 +232,15 @@ func ApplyKeeping( log(fmt.Sprintf(" forgotten %s (%s): a former target left in place: %v", orphan.ID, orphan.Target, err)) return nil } + if errors.Is(err, errRemovalWaits) { + // Kept recorded and said, never fatal: tried again by the next apply (novox/hq ADR 0177). + report.Outcomes = append(report.Outcomes, Outcome{ + ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, + Action: "waiting", Detail: err.Error(), + }) + log(fmt.Sprintf(" waiting %s (%s): %v", orphan.ID, orphan.Target, err)) + return nil + } if err != nil { return &Error{Resource: orphan.ID, Err: err, Done: report} } @@ -572,6 +587,19 @@ func ApplyKeeping( continue } + // **Waiting is not applied** (novox/hq ADR 0177): a user-scoped unit whose account's manager + // is not running was not touched, so its record — if it has one — stays exactly as it was, + // what was found included, and none is written for one never applied. What one whose + // manager stopped part way found is kept as a failure's is, for the apply that finishes it. + if outcome.Action == "waiting" { + if outcome.found != nil { + known.KeepFound(resource.Identity(), origin, *outcome.found) + } + report.Outcomes = append(report.Outcomes, outcome) + log(fmt.Sprintf(" waiting %s (%s): %s", outcome.ID, outcome.Target, outcome.Detail)) + continue + } + // Where the original of what this file replaced was kept, carried for as long as the // resource is recorded: kept by this apply, by a hold its module's cutover ends, or before. held, wasHeld := known.HeldAt(resource.Identity()) @@ -592,8 +620,11 @@ func ApplyKeeping( Kept: kept, Reads: outcome.reads, Stateless: outcome.stateless, + Scope: outcome.scope, + User: outcome.user, Found: outcome.found, Shell: outcome.shell, + Linger: outcome.linger, Unpacked: outcome.unpacked, Holds: holds(resource), }) @@ -1126,12 +1157,61 @@ func stayedRunning(ctx context.Context, sys system.System, run Runner, unit stri return sys.ServiceState(ctx, run, unit) } +// applyService puts a unit into its declared state, in the manager it belongs to. +// +// **A user-scoped unit waits for its account's manager** (novox/hq ADR 0177). That manager runs +// from the account's first login to its last logout, or always while the account lingers; with +// neither, there is nothing to start the unit in, and asking would log the account in for the +// length of the question (see UserManagerRunning). A unit that fails every apply until somebody logs +// in is a node reported broken for being switched on, so it is said — "waiting", recorded as it +// was, nothing claimed — and the first apply after the manager starts applies it. One the manager +// itself starts at login needs nothing from the mesh: enabled is enabled in the account's own +// manager, and that starts what is enabled when it starts. func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner, changed map[string]bool, previous store.Applied) (Outcome, error) { + if !r.UserScoped() { + return applyServiceIn(ctx, sys, r, run, changed, previous) + } + away, err := managerAway(ctx, sys, r.Scope, r.User, run) + if err != nil { + return begin(r), err + } + if away != "" { + return waitingFor(r, away), nil + } + out, err := applyServiceIn(ctx, sys, r, run, changed, previous) + if err != nil { + // A manager that stopped while the apply was at it — the person logged out — is the same + // absence found a moment later, and is said the same way rather than failed. + // What was found before it went is carried, as a failure's is (novox/hq ADR 0118). + if away, _ := managerAway(ctx, sys, r.Scope, r.User, run); away != "" { + waiting := waitingFor(r, away+", having stopped during this apply ("+err.Error()+")") + waiting.found = out.found + return waiting, nil + } + } + return out, err +} + +// waitingFor is a user-scoped unit whose account's manager is not running (novox/hq ADR 0177). +func waitingFor(r *declaration.Service, away string) Outcome { + out := begin(r) + out.scope, out.user = r.Scope, r.User + out.Action = "waiting" + out.Detail = away + "; applied by the first apply after it starts — a login, or the account " + + "declared to linger" + return out +} + +// applyServiceIn is applyService, in the manager the unit belongs to; raw reaches the machine's. +func applyServiceIn(ctx context.Context, sys system.System, r *declaration.Service, raw Runner, + changed map[string]bool, previous store.Applied) (Outcome, error) { + run := managerFor(r.Scope, r.User, raw) 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 @@ -1151,7 +1231,7 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service // running, from the mesh's packet filter, stopped until the mesh started it and to be stopped // again. Read after the reload above, never before it: a unit whose file this same apply wrote // is not a unit the service manager knows until then, and reading it first would find nothing. - found, gaveBack, err := foundAs(ctx, sys, r, run, previous) + found, gaveBack, err := foundAs(ctx, sys, r, raw, previous) if err != nil { return out, err } @@ -1275,6 +1355,7 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner, changed map[string]bool) (Outcome, error) { out := begin(r) + out.scope, out.user = r.Scope, r.User out.stateless = true restart := reflected(r, changed) reload := restartedBy(r.ReloadOn, changed) @@ -1395,7 +1476,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner, return "removed", "no longer declared", nil case declaration.TypeService: - return removeService(ctx, sys, a, run, made[a.Target]) + return removeService(ctx, sys, a, run, made[unitKey(a.Scope, a.User, a.Target)]) case declaration.TypeProcess: // The other side of the same line: a process's unit is the host's own — it wrote the unit @@ -2372,6 +2453,48 @@ func removeService(ctx context.Context, sys system.System, a store.Applied, run // the link the operator would use to do it. return "forgotten", "recorded before the host kept what it found; left as it is", nil } + if a.Scope == declaration.ScopeUser { + return removeUserUnit(ctx, sys, a, run, made) + } + return giveUnitBack(ctx, sys, a, run, made) +} + +// errRemovalWaits is a removal that cannot happen yet and is not a failure: the record stays, the +// outcome says why, and the next apply tries again (novox/hq ADR 0177). +var errRemovalWaits = errors.New("not removed yet") + +// removeUserUnit gives back a unit in an account's own manager (novox/hq ADR 0177). +// +// **Never fatal.** A removal that fails stops the apply, and its record is there to fail the same +// way on the next one — every machine wedged on one undeclared thing, which is what issues 162 and +// 228 each ended for their own shape. An account's manager is absent for an ordinary reason, the +// person logged out, so its unit would be the commonest such wedge there is. With no manager the +// unit is kept recorded and said to wait; the first apply that finds the manager running gives it +// back. An account that is gone took its manager and its units with it, and is forgotten. +func removeUserUnit(ctx context.Context, sys system.System, a store.Applied, run Runner, + made bool) (string, string, error) { + away, err := managerAway(ctx, sys, a.Scope, a.User, run) + switch { + case errors.Is(err, errNoAccount): + return "forgotten", "the account " + a.User + " is no longer on this machine, and its manager with it", nil + case err != nil: + return "", "", fmt.Errorf("%w: %v", errRemovalWaits, err) + case away != "": + return "", "", fmt.Errorf("%w: %s; given back by the first apply after it starts", errRemovalWaits, away) + } + action, detail, err := giveUnitBack(ctx, sys, a, managerFor(a.Scope, a.User, run), made) + if err != nil { + if away, _ := managerAway(ctx, sys, a.Scope, a.User, run); away != "" { + return "", "", fmt.Errorf("%w: %s, having stopped during this apply (%v)", errRemovalWaits, away, err) + } + return "", "", fmt.Errorf("%w: in %s's own manager: %v", errRemovalWaits, a.User, err) + } + return action, detail, nil +} + +// giveUnitBack is removeService's giving back, in whichever manager run reaches. +func giveUnitBack(ctx context.Context, sys system.System, a store.Applied, run Runner, + made bool) (string, string, error) { stop := made || a.Found.State == "stopped" disable := made || a.Found.Boot == "disabled" @@ -2460,30 +2583,43 @@ func removeService(ctx context.Context, sys system.System, a store.Applied, run // which the mesh never starts or stops, or of another unit altogether. What was found about one // unit says nothing about another, so a service moved to a new unit gives the old one back, just as // if it had been undeclared, and is read afresh for the new one; gave says what that gave back. +// +// A unit of the same name in another manager is another unit (novox/hq ADR 0177): a service moved +// from the machine's manager to an account's, or between accounts, gives the old one back through +// the manager it was in. run reaches the machine's manager; each side is routed from it. func foundAs(ctx context.Context, sys system.System, r *declaration.Service, run Runner, previous store.Applied) (found *store.FoundUnit, gave string, err error) { + // What a record says about its manager, only where there is a record: what an unfinished apply + // found is kept by identity alone, and was read in the manager the declaration names. + sameManager := previous.ID == "" || unitKey(previous.Scope, previous.User, "") == unitKey(r.Scope, r.User, "") if f := previous.Found; f != nil { unit := f.Unit if unit == "" { unit = previous.Target } - if unit == "" || unit == r.Unit { + if (unit == "" || unit == r.Unit) && sameManager { return f, "", nil } // Given back as if undeclared, never as the mesh's own: whether the mesh wrote the old // unit's file is known to the removal of orphans, and that file's own record goes with it. action, detail, err := removeService(ctx, sys, - store.Applied{Type: string(declaration.TypeService), Target: unit, Found: f}, run, false) - if err != nil { + store.Applied{Type: string(declaration.TypeService), Target: unit, Found: f, + Scope: previous.Scope, User: previous.User}, run, false) + switch { + case errors.Is(err, errRemovalWaits): + // The old unit's account manager is not there to give it back to; the new unit is not + // held up for it, and the giving back is said rather than silently dropped. + gave = unit + " not given back: " + err.Error() + case err != nil: return nil, "", fmt.Errorf("giving %s back as the host found it, now that %s is declared instead: %w", unit, r.Unit, err) - } - if action == "restored" { + case action == "restored": gave = unit + " " + detail } - } else if previous.ID != "" && !previous.Stateless && previous.Target == r.Unit { + } else if previous.ID != "" && !previous.Stateless && previous.Target == r.Unit && sameManager { return nil, "", nil } + run = managerFor(r.Scope, r.User, run) state, err := sys.ServiceState(ctx, run, r.Unit) if err != nil { return nil, gave, err @@ -2498,22 +2634,74 @@ func foundAs(ctx context.Context, sys system.System, r *declaration.Service, run // // A drop-in is not the unit's own file, and a file the host wrote over is the machine's unit with // the mesh's text in it: its original is kept, and put back when the file's record goes. +// +// **Keyed by manager and name** (novox/hq ADR 0177): an account's unit and the machine's of the same +// name are two units, and the mesh writing one says nothing about the other. An account's manager +// loads an administrator's units from the account's own ~/.config/systemd/user, and from +// /etc/systemd/user for every account; a unit there is the mesh's for each user-scoped record of it. func meshMadeUnits(known store.State) map[string]bool { made := map[string]bool{} dirs := map[string]bool{"/etc/systemd/system": true, "/run/systemd/system": true, filepath.Clean(unitDir): true} for _, r := range known.Resources { - if r.Type != string(declaration.TypeFile) || r.Kept != "" || r.Into != nil { + if !wroteWhole(r) { continue } path := filepath.Clean(r.Target) if dirs[filepath.Dir(path)] { - made[filepath.Base(path)] = true + made[unitKey(declaration.ScopeSystem, "", filepath.Base(path))] = true + } + } + for _, r := range known.Resources { + if r.Type != string(declaration.TypeService) || r.Scope != declaration.ScopeUser { + continue + } + for _, path := range userUnitFiles(r.User, r.Target) { + if meshWroteWhole(known, path) { + made[unitKey(r.Scope, r.User, r.Target)] = true + } } } return made } +// wroteWhole is a file record of a file the host wrote whole where there was none. +func wroteWhole(r store.Applied) bool { + return r.Type == string(declaration.TypeFile) && r.Kept == "" && r.Into == nil +} + +// meshWroteWhole is whether this host wrote the file at path whole, where there was none. +func meshWroteWhole(known store.State, path string) bool { + path = filepath.Clean(path) + for _, r := range known.Resources { + if wroteWhole(r) && filepath.Clean(r.Target) == path { + return true + } + } + return false +} + +// userUnitFiles is where an account's manager loads an administrator's unit from: the one every +// account's manager reads, and the account's own. The account's is left out when its home cannot be +// read, which only makes fewer files the mesh's. +func userUnitFiles(account, unit string) []string { + paths := []string{filepath.Join("/etc/systemd/user", unit)} + if home, err := homeOf(account); err == nil && home != "" { + paths = append(paths, filepath.Join(home, ".config", "systemd", "user", unit)) + } + return paths +} + +// unitKey names a unit by the manager it is in and its name (novox/hq ADR 0177): the machine's +// manager, or one account's. A system unit is the same key whether its record says "system" or +// nothing, as every record before the scope existed does. +func unitKey(scope, user, unit string) string { + if scope == declaration.ScopeUser { + return "user:" + user + "/" + unit + } + return "system/" + unit +} + // moduleOf is the module a declared resource belongs to. // // The mesh composes a module's resource ids as `.`, and **a module's name may @@ -2634,3 +2822,70 @@ func appliedIDs(applied []store.Applied) string { } return strings.Join(ids, ", ") } + +// 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. +// +// **The host's environment is not what reaches the account** (point 4 of the review). The +// `--machine` transport does not read XDG_RUNTIME_DIR or DBUS_SESSION_BUS_ADDRESS: it asks the +// machine's manager, over the system bus, to run `systemd-stdio-bridge --user` as the account in +// a login of its own, whose runtime directory and bus are the account's. So the host, root under +// the machine's manager with neither variable set, needs only the system bus and `systemd-run` on +// its path — both of which a systemd machine has. Only the account itself would be reached through +// its own environment instead (systemctl short-cuts to the local bus when the caller is the +// account), and the host is never the account. Being root is what lets it ask: anyone else is +// refused by the machine's manager, and the refusal is the error the unit fails with. +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...) + } +} + +// userManagers is a service manager that runs a manager per account (novox/hq ADR 0177). +type userManagers interface { + UserManagerRunning(ctx context.Context, run system.Runner, account string) (running, exists bool, err error) +} + +// errNoAccount is a user-scoped unit naming an account the machine does not have. +var errNoAccount = errors.New("no such account on this machine") + +// managerAway is why the manager a unit is in cannot be reached now, or "" when it can — always +// for a system unit (novox/hq ADR 0177). Asked of the machine's manager through run, never of the +// account's, which a question would start (system.UserManagerRunning says why). +// +// Refused outright: an account the machine does not have, as ADR 0177 §1 requires, and a machine +// whose service manager has no manager per account — where a user-scoped unit would otherwise be +// applied as the machine's unit of the same name. +func managerAway(ctx context.Context, sys system.System, scope, user string, run Runner) (string, error) { + if scope != declaration.ScopeUser { + return "", nil + } + um, ok := sys.(userManagers) + if !ok { + return "", fmt.Errorf("a unit in %s's own manager, and this machine's service manager (%s) has "+ + "no manager per account (novox/hq ADR 0177)", user, sys.Name()) + } + running, exists, err := um.UserManagerRunning(ctx, system.Runner(run), user) + switch { + case err != nil: + return "", err + case !exists: + return "", fmt.Errorf("%w: %q, whose own manager a user-scoped unit lives in (novox/hq ADR 0177)", + errNoAccount, user) + case !running: + return user + "'s own service manager is not running: the account is not logged in and does not linger", nil + } + return "", nil +} diff --git a/internal/apply/found_test.go b/internal/apply/found_test.go index 01a5248..047436c 100644 --- a/internal/apply/found_test.go +++ b/internal/apply/found_test.go @@ -122,8 +122,8 @@ func TestOnlyAUnitsOwnFileTheMeshCreatedMakesItTheMeshs(t *testing.T) { made := meshMadeUnits(known) for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false, "into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} { - if made[unit] != want { - t.Errorf("%s: made %v, want %v", unit, made[unit], want) + if made[unitKey("", "", unit)] != want { + t.Errorf("%s: made %v, want %v", unit, made[unitKey("", "", unit)], want) } } } diff --git a/internal/apply/hold.go b/internal/apply/hold.go index fbd871b..72a4011 100644 --- a/internal/apply/hold.go +++ b/internal/apply/hold.go @@ -109,9 +109,30 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati } } case *declaration.Service: - if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) { + if res.Stateless() || recordedUnit(known, res) { continue } + key := "unit:" + unitKey(res.Scope, res.User, res.Unit) + run := run + if res.UserScoped() { + // In the account's own manager (novox/hq ADR 0177), and only asked while it runs. With + // it not running, what can be known is whether somebody put the unit's file where that + // manager loads an administrator's units from — and the mesh's own file there is not + // somebody's. An account the machine does not have has no unit to find. + away, err := managerAway(ctx, sys, res.Scope, res.User, run) + if err != nil { + continue + } + if away != "" { + for _, path := range userUnitFiles(res.User, res.Unit) { + if present(path) && !meshWroteWhole(known, path) { + seen.is[key] = true + } + } + continue + } + run = managerFor(res.Scope, res.User, run) + } // **Found is a unit somebody put on this machine, or one the machine uses.** // // Where it comes from first: a unit the service manager loads from outside /usr — @@ -129,14 +150,14 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati continue } if from, ok := sys.(unitFiles); ok { - if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(path) { - seen.is["unit:"+res.Unit] = true + if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(known, path) { + seen.is[key] = true continue } } boot, _ := sys.ServiceBoot(ctx, run, res.Unit) if state == "running" || boot == "enabled" { - seen.is["unit:"+res.Unit] = true + seen.is[key] = true } case *declaration.Container: if known.Recorded(string(declaration.TypeContainer), res.Name) { @@ -177,12 +198,26 @@ type unitFiles interface { } // installedByHand is whether a unit file is one somebody put on this machine rather than one a -// package ships: anywhere but /usr, where distributions keep what they install. -func installedByHand(path string) bool { +// package ships: anywhere but /usr, where distributions keep what they install — and not one this +// host wrote whole. That covers an account's units (novox/hq ADR 0177): one under the account's +// ~/.config/systemd/user or /etc/systemd/user is somebody's, unless the mesh wrote it there. +func installedByHand(known store.State, path string) bool { if path == "" { return false } - return !strings.HasPrefix(filepath.Clean(path), "/usr/") + return !strings.HasPrefix(filepath.Clean(path), "/usr/") && !meshWroteWhole(known, path) +} + +// recordedUnit is whether this host has a record of a service in the same manager as res, under the +// same name (novox/hq ADR 0177): an account's unit and the machine's of one name are two units. +func recordedUnit(known store.State, res *declaration.Service) bool { + want := unitKey(res.Scope, res.User, res.Unit) + for _, r := range known.Resources { + if r.Type == string(declaration.TypeService) && unitKey(r.Scope, r.User, r.Target) == want { + return true + } + } + return false } func present(path string) bool { @@ -374,7 +409,7 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc case *declaration.User: isFound = before.has("user:" + res.Name) case *declaration.Service: - isFound = before.has("unit:" + res.Unit) + isFound = before.has("unit:" + unitKey(res.Scope, res.User, res.Unit)) } } if !isFound { @@ -388,7 +423,11 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc // A held service is not started, stopped, enabled or restarted — but a reload stops nothing, // so one the module names still happens (novox/hq ADR 0102, ADR 0103). if svc, ok := r.(*declaration.Service); ok && svc.State == "running" { - if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 { + // In the unit's own manager, and nothing to reload in one that is not running (novox/hq ADR + // 0177): the state read below then fails, and the reload is not attempted. + away, awayErr := managerAway(ctx, sys, svc.Scope, svc.User, run) + run := managerFor(svc.Scope, svc.User, run) + if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 && awayErr == nil && away == "" { if state, err := sys.ServiceState(ctx, run, svc.Unit); err == nil && state == "running" { reloader, can := sys.(serviceReloader) if !can { @@ -711,6 +750,20 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module } detail = "the user was found on the machine; its shell and groups are kept until " + module + " is taken" case *declaration.Service: + if res.UserScoped() { + // Read in the account's own manager, and not at all while it is not running (novox/hq + // ADR 0177): held as found, with nothing to compare until it runs. + away, err := managerAway(ctx, sys, res.Scope, res.User, run) + if err != nil { + return out, h, err + } + if away != "" { + detail = "its unit was found in " + res.User + "'s own manager, which is not running; " + + "kept as found until " + module + " is taken" + break + } + run = managerFor(res.Scope, res.User, run) + } state, err := sys.ServiceState(ctx, run, res.Unit) switch { case err != nil && !already: diff --git a/internal/apply/plan.go b/internal/apply/plan.go index 9c89761..6bb724a 100644 --- a/internal/apply/plan.go +++ b/internal/apply/plan.go @@ -105,7 +105,7 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step { // stopped whatever was found. f := orphan.Found switch { - case made[orphan.Target]: + case made[unitKey(orphan.Scope, orphan.User, orphan.Target)]: step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+ "stopped and disabled at boot before that file goes" case f == nil: diff --git a/internal/apply/user.go b/internal/apply/user.go index d349cac..3d5b16c 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -38,6 +38,10 @@ func applyUser(ctx context.Context, sys system.System, r *declaration.User, run kept := *previous.Shell out.shell = &kept } + if previous.Linger != nil && previous.Target == r.Name { + kept := *previous.Linger + out.linger = &kept + } login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name) if err != nil { @@ -123,11 +127,71 @@ func applyUser(ctx context.Context, sys system.System, r *declaration.User, run out.Action = "updated" } } + + // Lingering last, and only when declared and different: the one change here that starts or + // stops something — the account's manager and every unit in it (novox/hq ADR 0177). + if r.Linger != nil { + changed, err := applyLinger(ctx, sys, r.Name, *r.Linger, run, &out) + if err != nil { + return out, err + } + if changed && out.Action == "unchanged" { + out.Action = "updated" + } + } return out, nil } +// lingerer is a service manager that runs a manager per account, and can keep one running with +// nobody logged in (novox/hq ADR 0177). +type lingerer interface { + Lingering(ctx context.Context, run system.Runner, name string) (bool, error) + SetLingering(ctx context.Context, run system.Runner, name string, on bool) error +} + +// applyLinger makes whether an account lingers what was declared, read back from the machine, and +// keeps what it found the first time it changed it so removal can give that back. +func applyLinger(ctx context.Context, sys system.System, name string, want bool, run Runner, + out *Outcome) (bool, error) { + l, ok := sys.(lingerer) + if !ok { + return false, fmt.Errorf("%q is declared to linger, and this machine's service manager has no "+ + "manager per account to keep running (novox/hq ADR 0177)", name) + } + now, err := l.Lingering(ctx, system.Runner(run), name) + if err != nil { + return false, err + } + if now == want { + return false, nil + } + if err := l.SetLingering(ctx, system.Runner(run), name, want); err != nil { + return false, err + } + if back, err := l.Lingering(ctx, system.Runner(run), name); err != nil { + return false, err + } else if back != want { + return false, fmt.Errorf("asked logind to make %q linger %s, and it reads %s", + name, onOff(want), onOff(back)) + } + // Found once, as the shell's is: what goes back is what was there before the mesh. + if out.linger == nil { + out.linger = &store.Lingering{Found: now} + } + out.linger.Set = want + return true, nil +} + +func onOff(on bool) string { + if on { + return "on" + } + return "off" +} + // removeUser is what undeclaring a login does: never deleting the account, and giving back the -// shell the mesh replaced when that is still safe (novox/hq ADR 0176 §2, issue 228). +// shell the mesh replaced when that is still safe (novox/hq ADR 0176 §2, issue 228) — and whether +// it lingered, on the same rule (novox/hq ADR 0177). // // **The account is never deleted, whether or not the mesh created it.** An account owns a home, // files, a crontab, a mailbox — what a person did with it is not the mesh's to know, and deleting @@ -148,6 +212,23 @@ func removeUser(ctx context.Context, sys system.System, a store.Applied, run Run if !exists { return "forgotten", "no longer there", nil } + action, detail, err := giveShellBack(ctx, sys, a, login, run, kept) + if err != nil { + return "", "", err + } + if gave, said := giveLingerBack(ctx, sys, a, run); said != "" { + detail += "; " + said + if gave { + action = "restored" + } + } + return action, detail, nil +} + +// giveShellBack is removeUser's shell: given back only while the account still has the one the +// mesh set, and only to one still usable. +func giveShellBack(ctx context.Context, sys system.System, a store.Applied, login system.Login, + run Runner, kept string) (string, string, error) { found := a.Shell switch { case found == nil: @@ -179,6 +260,41 @@ func removeUser(ctx context.Context, sys system.System, a store.Applied, run Run return "restored", fmt.Sprintf("%s; the shell it had before the mesh, %s, given back", kept, found.Found), nil } +// giveLingerBack is removeUser's lingering (novox/hq ADR 0177), on the shell's rule: whether the +// account lingered before the mesh changed it goes back, and only while the account still has what +// the mesh set — one the operator changed since with loginctl is theirs. Never fatal, for the +// shell's reason. Empty when the mesh never changed it, so a removal says nothing about it. +// +// Giving back "not lingering" stops the account's manager if nobody is logged in, and every unit +// in it: that is what the account had before the mesh, and its user units go with its declaration. +func giveLingerBack(ctx context.Context, sys system.System, a store.Applied, run Runner) (bool, string) { + found := a.Linger + if found == nil { + return false, "" + } + if found.Found == found.Set { + return false, "" + } + l, ok := sys.(lingerer) + if !ok { + return false, "" + } + now, err := l.Lingering(ctx, system.Runner(run), a.Target) + if err != nil { + return false, fmt.Sprintf("whether it lingered before the mesh could not be given back: %v", err) + } + if now != found.Set { + return false, fmt.Sprintf("lingering left %s: changed since the mesh set it %s", onOff(now), onOff(found.Set)) + } + if err := l.SetLingering(ctx, system.Runner(run), a.Target, found.Found); err != nil { + return false, fmt.Sprintf("lingering, %s before the mesh, could not be given back: %v", onOff(found.Found), err) + } + if back, err := l.Lingering(ctx, system.Runner(run), a.Target); err != nil || back != found.Found { + return false, fmt.Sprintf("gave back lingering %s and logind does not read it so", onOff(found.Found)) + } + return true, fmt.Sprintf("lingering %s again, as before the mesh", onOff(found.Found)) +} + // own sets a path's owner, when one was declared. // // Looked up by name every time rather than cached: a user's numeric id is not stable across diff --git a/internal/apply/userscope_test.go b/internal/apply/userscope_test.go new file mode 100644 index 0000000..5c9a8a1 --- /dev/null +++ b/internal/apply/userscope_test.go @@ -0,0 +1,558 @@ +package apply + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" +) + +// 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. The account's +// manager runs only while somebody is logged in or the account lingers: with it away a unit waits +// rather than fails, a removal is never fatal, and the manager is never started by asking it. + +// account is a machine with one account whose own manager runs or does not, and the units in it. +type account struct { + name, uid, home string + // up is whether the account's manager runs: a login, or lingering. + up bool + // lingers is logind's record, kept as files in a directory a test owns. + lingerDir string + // units are the account's units by name; system are the machine's. + units, system map[string]*fakeUnit + // busDown is a manager that says it runs while its bus does not answer — it stopped between + // the question and the command. + busDown bool + asked []string + // strays are system-scope commands that reached a unit, which no test here expects unless it + // declares a system unit. + strays []string +} + +func newAccount(t *testing.T, up bool) *account { + t.Helper() + was := serviceSettle + serviceSettle = 0 + dir := t.TempDir() + restore := system.LingerIn(dir) + t.Cleanup(func() { serviceSettle = was; restore() }) + return &account{name: "ops", uid: "1001", home: "/home/ops", up: up, lingerDir: dir, + units: map[string]*fakeUnit{"i3-reload-watcher.service": {active: "inactive", enabled: "disabled"}}, + system: map[string]*fakeUnit{}} +} + +func (a *account) did(prefix string) bool { + for _, c := range a.asked { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +func (a *account) run(_ context.Context, name string, args ...string) (string, error) { + line := name + " " + strings.Join(args, " ") + a.asked = append(a.asked, line) + switch name { + case "getent": + if args[len(args)-1] == a.name { + return a.name + ":x:" + a.uid + ":" + a.uid + "::" + a.home + ":/bin/bash\n", nil + } + return "", errors.New("getent exited 2: ") + case "loginctl": + path := filepath.Join(a.lingerDir, args[1]) + switch args[0] { + case "enable-linger": + a.up = true + return "", os.WriteFile(path, nil, 0o644) + case "disable-linger": + a.up = false + return "", os.Remove(path) + } + case "systemctl": + if line == "systemctl is-active user@"+a.uid+".service" { + if a.up { + return "active\n", nil + } + return "inactive\n", errors.New("systemctl exited 3: ") + } + prefix := "--machine=" + a.name + "@" + if len(args) > 1 && args[0] == "--user" && args[1] == prefix { + if !a.up || a.busDown { + return "", errors.New("systemctl exited 1: Failed to connect to user scope bus via " + + "machine transport: No such file or directory") + } + return unitCommand(a.units, args[2:]) + } + a.strays = append(a.strays, line) + return unitCommand(a.system, args) + } + return "", nil +} + +// unitCommand is one systemctl verb against a set of units. +func unitCommand(units map[string]*fakeUnit, args []string) (string, error) { + if len(args) == 0 { + return "", nil + } + if args[0] == "daemon-reload" { + return "", nil + } + u, ok := units[args[1]] + switch args[0] { + case "show": + if !ok { + return "LoadState=not-found\nActiveState=inactive", nil + } + return "LoadState=loaded\nActiveState=" + u.active, nil + case "is-enabled": + if !ok { + return "", errors.New("systemctl exited 1: ") + } + return u.enabled + "\n", nil + } + if !ok { + return "", fmt.Errorf("systemctl exited 5: Unit %s not found", args[1]) + } + switch args[0] { + case "start", "restart": + u.active = "active" + case "stop": + u.active = "inactive" + case "enable": + u.enabled = "enabled" + case "disable": + u.enabled = "disabled" + } + return "", nil +} + +const watcher = `{"id":"i3.watcher","type":"service","unit":"i3-reload-watcher.service","state":"running","boot":"enabled","scope":"user","user":"ops"}` + +func declaring(resources ...string) string { + return `{"declaration":1,"resources":[` + strings.Join(resources, ",") + `]}` +} + +func applyAccount(t *testing.T, raw string, known store.State, a *account) (Report, store.State, error) { + t.Helper() + return Apply(context.Background(), archHost(t), parse(t, raw), known, store.OriginDeclared, a.run, nil, nil) +} + +func outcomeFor(r Report, id string) Outcome { + for _, o := range r.Outcomes { + if o.ID == id { + return o + } + } + return Outcome{} +} + +func TestAUserScopedUnitIsAppliedThroughTheAccountsManager(t *testing.T) { + a := newAccount(t, true) + report, known, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err != nil { + t.Fatalf("apply: %v\n%s", err, strings.Join(a.asked, "\n")) + } + if !report.Changed() { + t.Fatal("a unit that was stopped and is now running changed nothing") + } + if !a.did("systemctl --user --machine=ops@ start i3-reload-watcher.service") || + !a.did("systemctl --user --machine=ops@ enable i3-reload-watcher.service") { + t.Fatalf("the unit was not started and enabled in the account's manager:\n%s", strings.Join(a.asked, "\n")) + } + if len(a.strays) != 0 { + t.Fatalf("a user-scoped unit reached the machine's manager: %v", a.strays) + } + recorded, ok := known.At("service", "i3-reload-watcher.service") + if !ok || recorded.Scope != "user" || recorded.User != "ops" || recorded.Found == nil { + t.Fatalf("the record does not say whose manager the unit is in, or what was found: %+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") || strings.Contains(c, "user@") { + t.Fatalf("a system unit was addressed to an account's manager: %s", c) + } + } +} + +// With nobody logged in and no lingering, the unit waits: not a failure, nothing recorded, and the +// account's manager never asked — asking it would log the account in. +func TestAUserUnitWaitsForItsAccountsManagerAndIsAppliedWhenItRuns(t *testing.T) { + a := newAccount(t, false) + report, known, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err != nil { + t.Fatalf("a unit whose account is not logged in failed the apply: %v", err) + } + o := outcomeFor(report, "i3.watcher") + if o.Action != "waiting" || !strings.Contains(o.Detail, "not running") { + t.Fatalf("the outcome does not say the unit waits for its manager: %+v", o) + } + if report.Changed() { + t.Error("a unit that waited is reported as a change") + } + if a.did("systemctl --user") { + t.Fatalf("the account's manager was asked while it was not running:\n%s", strings.Join(a.asked, "\n")) + } + if _, ok := known.Find("i3.watcher"); ok { + t.Fatal("a unit never applied was recorded") + } + + // The person logs in. + a.up = true + report, known, err = applyAccount(t, declaring(watcher), known, a) + if err != nil { + t.Fatal(err) + } + if o := outcomeFor(report, "i3.watcher"); o.Action == "waiting" || a.units["i3-reload-watcher.service"].active != "active" { + t.Fatalf("the unit was not applied once its manager ran: %+v", o) + } + if _, ok := known.Find("i3.watcher"); !ok { + t.Fatal("the unit was applied and not recorded") + } +} + +// A unit applied before, its account since logged out: the record stays exactly as it was, what +// was found included, so a later removal still gives back what was there before the mesh. +func TestAWaitingUnitKeepsItsRecord(t *testing.T) { + a := newAccount(t, true) + _, known, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err != nil { + t.Fatal(err) + } + before, _ := known.Find("i3.watcher") + a.up = false + _, known, err = applyAccount(t, declaring(watcher), known, a) + if err != nil { + t.Fatal(err) + } + after, ok := known.Find("i3.watcher") + if !ok || after.Found == nil || *after.Found != *before.Found || after.Scope != "user" { + t.Fatalf("waiting changed the record: before %+v, after %+v", before, after) + } +} + +// A manager that says it runs and then does not answer — the person logged out mid-apply — is the +// same absence, and is said the same way. +func TestAManagerThatStopsDuringTheApplyIsWaitedFor(t *testing.T) { + a := newAccount(t, true) + a.busDown = true + calls := 0 + run := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "systemctl" && len(args) == 2 && args[0] == "is-active" { + calls++ + if calls > 1 { + a.up = false + } + } + return a.run(ctx, name, args...) + } + report, _, err := Apply(context.Background(), archHost(t), parse(t, declaring(watcher)), store.State{}, + store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatalf("a manager that went away mid-apply failed it: %v", err) + } + if o := outcomeFor(report, "i3.watcher"); o.Action != "waiting" { + t.Fatalf("not waiting: %+v", o) + } +} + +func TestAUserUnitOfAnAccountTheMachineDoesNotHaveIsRefused(t *testing.T) { + a := newAccount(t, true) + a.name = "someone-else" + _, _, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err == nil || !strings.Contains(err.Error(), `"ops"`) { + t.Fatalf("a unit of an account that is not here was not refused naming it: %v", err) + } +} + +// Without a manager per account — OpenRC — a user-scoped unit would otherwise be applied as the +// machine's service of the same name. Refused instead. +func TestAUserUnitIsRefusedWhereTheServiceManagerHasNoAccounts(t *testing.T) { + alpine, err := system.For("alpine") + if err != nil { + t.Fatal(err) + } + a := newAccount(t, true) + _, _, err = Apply(context.Background(), alpine, parse(t, declaring(watcher)), store.State{}, + store.OriginDeclared, a.run, nil, nil) + if err == nil || !strings.Contains(err.Error(), "no manager per account") { + t.Fatalf("not refused: %v", err) + } + if a.did("rc-service") { + t.Fatalf("a user-scoped unit reached the machine's services: %v", a.asked) + } +} + +// **Removal is never fatal** (the issue 162/228 wedge): with the manager away the record stays and +// the outcome says it waits; the first apply that finds the manager gives the unit back. +func TestRemovingAUserUnitWithItsManagerAwayWaitsAndIsNeverFatal(t *testing.T) { + a := newAccount(t, true) + _, known, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err != nil { + t.Fatal(err) + } + a.up = false + report, known, err := applyAccount(t, nothingButA(t), known, a) + if err != nil { + t.Fatalf("undeclaring a unit whose account is logged out failed the apply: %v", err) + } + if o := outcomeFor(report, "i3.watcher"); o.Action != "waiting" || !strings.Contains(o.Detail, "not running") { + t.Fatalf("the removal does not say it waits: %+v", o) + } + if _, ok := known.Find("i3.watcher"); !ok { + t.Fatal("a unit not given back was forgotten") + } + + a.up = true + report, known, err = applyAccount(t, nothingButA(t), known, a) + if err != nil { + t.Fatal(err) + } + u := a.units["i3-reload-watcher.service"] + if u.active != "inactive" || u.enabled != "disabled" { + t.Fatalf("the unit was not given back as found: %+v (%+v)", u, outcomeFor(report, "i3.watcher")) + } + if _, ok := known.Find("i3.watcher"); ok { + t.Fatal("a unit given back is still recorded") + } + if len(a.strays) != 0 { + t.Fatalf("the removal reached the machine's manager: %v", a.strays) + } +} + +// "Failed to connect to bus" from a manager that said it ran is said and retried, never fatal. +func TestRemovingAUserUnitWhoseBusDoesNotAnswerIsNotFatal(t *testing.T) { + a := newAccount(t, true) + _, known, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err != nil { + t.Fatal(err) + } + a.busDown = true + report, known, err := applyAccount(t, nothingButA(t), known, a) + if err != nil { + t.Fatalf("a bus that did not answer made the removal fatal: %v", err) + } + if o := outcomeFor(report, "i3.watcher"); o.Action != "waiting" || !strings.Contains(o.Detail, "Failed to connect") { + t.Fatalf("the removal does not say what stopped it: %+v", o) + } + if _, ok := known.Find("i3.watcher"); !ok { + t.Fatal("a unit not given back was forgotten") + } +} + +func TestRemovingAUserUnitOfAnAccountThatIsGoneForgetsIt(t *testing.T) { + a := newAccount(t, true) + _, known, err := applyAccount(t, declaring(watcher), store.State{}, a) + if err != nil { + t.Fatal(err) + } + a.name = "renamed" + report, known, err := applyAccount(t, nothingButA(t), known, a) + if err != nil { + t.Fatal(err) + } + if o := outcomeFor(report, "i3.watcher"); o.Action != "forgotten" || !strings.Contains(o.Detail, "no longer on this machine") { + t.Fatalf("%+v", o) + } + if _, ok := known.Find("i3.watcher"); ok { + t.Fatal("still recorded") + } +} + +// A unit file the mesh wrote under the account's own unit directory makes the unit the mesh's — and +// only the account's unit of that name, never the machine's. +func TestAUserUnitsFileTheMeshWroteMakesThatUnitAndNoOtherTheMeshs(t *testing.T) { + home := t.TempDir() + was := homeOf + homeOf = func(name string) (string, error) { + if name == "ops" { + return home, nil + } + return "", errors.New("no such account") + } + t.Cleanup(func() { homeOf = was }) + + known := store.State{Resources: []store.Applied{ + {ID: "f", Type: "file", Target: filepath.Join(home, ".config/systemd/user/watcher.service")}, + {ID: "g", Type: "file", Target: "/etc/systemd/user/shared.service"}, + {ID: "h", Type: "file", Target: filepath.Join(home, ".config/systemd/user/kept.service"), Kept: "/x"}, + {ID: "s1", Type: "service", Target: "watcher.service", Scope: "user", User: "ops"}, + {ID: "s2", Type: "service", Target: "shared.service", Scope: "user", User: "ops"}, + {ID: "s3", Type: "service", Target: "kept.service", Scope: "user", User: "ops"}, + {ID: "s4", Type: "service", Target: "watcher.service"}, + }} + made := meshMadeUnits(known) + for key, want := range map[string]bool{ + unitKey("user", "ops", "watcher.service"): true, + unitKey("user", "ops", "shared.service"): true, + unitKey("user", "ops", "kept.service"): false, + unitKey("", "", "watcher.service"): false, + unitKey("system", "", "shared.service"): false, + } { + if made[key] != want { + t.Errorf("%s: made %v, want %v", key, made[key], want) + } + } + if installedByHand(known, filepath.Join(home, ".config/systemd/user/watcher.service")) { + t.Error("a user unit file the mesh wrote reads as installed by hand") + } + if !installedByHand(known, filepath.Join(home, ".config/systemd/user/other.service")) { + t.Error("a user unit file somebody else put there reads as not installed by hand") + } + if installedByHand(known, "/usr/lib/systemd/user/pipewire.service") { + t.Error("a packaged user unit reads as installed by hand") + } +} + +// Undeclared together, the account's unit whose file the mesh wrote is stopped and disabled in the +// account's manager — whatever was found — and a system unit of the same name is not touched. +func TestAMeshMadeUserUnitIsStoppedInItsAccountAndTheMachinesNamesakeIsNot(t *testing.T) { + a := newAccount(t, true) + home := t.TempDir() + was := homeOf + homeOf = func(string) (string, error) { return home, nil } + t.Cleanup(func() { homeOf = was }) + unitFile := filepath.Join(home, ".config/systemd/user/i3-reload-watcher.service") + if err := os.MkdirAll(filepath.Dir(unitFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(unitFile, []byte("[Service]\n"), 0o644); err != nil { + t.Fatal(err) + } + a.units["i3-reload-watcher.service"] = &fakeUnit{active: "active", enabled: "enabled"} + a.system["i3-reload-watcher.service"] = &fakeUnit{active: "active", enabled: "enabled"} + known := store.State{Resources: []store.Applied{ + {ID: "i3.unit", Type: "file", Target: unitFile, Origin: store.OriginDeclared}, + {ID: "i3.watcher", Type: "service", Target: "i3-reload-watcher.service", Scope: "user", User: "ops", + Origin: store.OriginDeclared, Found: &store.FoundUnit{State: "running", Boot: "enabled"}}, + }} + if _, _, err := applyAccount(t, nothingButA(t), known, a); err != nil { + t.Fatal(err) + } + if u := a.units["i3-reload-watcher.service"]; u.active != "inactive" || u.enabled != "disabled" { + t.Fatalf("the mesh's own user unit was not stopped and disabled: %+v", u) + } + if u := a.system["i3-reload-watcher.service"]; u.active != "active" || u.enabled != "enabled" { + t.Fatalf("the machine's unit of the same name was touched: %+v %v", u, a.strays) + } +} + +// A service moved from the machine's manager into an account's is two units: the machine's is given +// back as it was found, through the machine's manager, and the account's is read afresh. +func TestAServiceMovedIntoAnAccountGivesTheMachinesUnitBack(t *testing.T) { + a := newAccount(t, true) + a.system["i3-reload-watcher.service"] = &fakeUnit{active: "active", enabled: "enabled"} + known := store.State{Resources: []store.Applied{ + {ID: "i3.watcher", Type: "service", Target: "i3-reload-watcher.service", Origin: store.OriginDeclared, + Found: &store.FoundUnit{Unit: "i3-reload-watcher.service", State: "stopped", Boot: "disabled"}}, + }} + _, known, err := applyAccount(t, declaring(watcher), known, a) + if err != nil { + t.Fatal(err) + } + if u := a.system["i3-reload-watcher.service"]; u.active != "inactive" || u.enabled != "disabled" { + t.Fatalf("the machine's unit was not given back as found: %+v", u) + } + if u := a.units["i3-reload-watcher.service"]; u.active != "active" { + t.Fatalf("the account's unit was not started: %+v", u) + } + r, _ := known.Find("i3.watcher") + if r.Found == nil || r.Found.State != "stopped" || r.Scope != "user" { + t.Fatalf("what was found is not the account's unit's: %+v", r) + } +} + +// Lingering is the account's (novox/hq ADR 0177): declared, set and read back; recorded with what +// was found; given back on removal while the account still has what the mesh set. +func TestLingeringIsDeclaredOnTheAccountAndGivenBack(t *testing.T) { + a := newAccount(t, false) + user := `{"id":"ops.login","type":"user","name":"ops","linger":true}` + report, known, err := applyAccount(t, declaring(user), store.State{}, a) + if err != nil { + t.Fatal(err) + } + if !a.did("loginctl enable-linger ops") || outcomeFor(report, "ops.login").Action != "updated" { + t.Fatalf("lingering was not enabled: %v", a.asked) + } + r, _ := known.Find("ops.login") + if r.Linger == nil || r.Linger.Found || !r.Linger.Set { + t.Fatalf("the record does not say what was found and set: %+v", r.Linger) + } + + a.asked = nil + report, known, err = applyAccount(t, declaring(user), known, a) + if err != nil { + t.Fatal(err) + } + if a.did("loginctl") || outcomeFor(report, "ops.login").Action != "unchanged" { + t.Fatalf("a second apply changed lingering: %v", a.asked) + } + + // And a user-scoped unit of the account now applies with nobody logged in. + if _, known, err = applyAccount(t, declaring(user, watcher), known, a); err != nil { + t.Fatal(err) + } + if a.units["i3-reload-watcher.service"].active != "active" { + t.Fatal("a lingering account's unit was not started") + } + + report, _, err = applyAccount(t, nothingButA(t), known, a) + if err != nil { + t.Fatal(err) + } + if !a.did("loginctl disable-linger ops") { + t.Fatalf("lingering was not given back: %v", a.asked) + } + if o := outcomeFor(report, "ops.login"); o.Action != "restored" || !strings.Contains(o.Detail, "lingering off") { + t.Fatalf("%+v", o) + } +} + +func TestLingeringTheOperatorChangedSinceIsLeft(t *testing.T) { + a := newAccount(t, false) + known := store.State{Resources: []store.Applied{{ID: "ops.login", Type: "user", Target: "ops", + Origin: store.OriginDeclared, Linger: &store.Lingering{Found: false, Set: true}}}} + // Not lingering now: somebody ran disable-linger since the mesh set it. + report, _, err := applyAccount(t, nothingButA(t), known, a) + if err != nil { + t.Fatal(err) + } + if a.did("loginctl") { + t.Fatalf("lingering the operator changed was changed back: %v", a.asked) + } + if o := outcomeFor(report, "ops.login"); !strings.Contains(o.Detail, "changed since") { + t.Fatalf("%+v", o) + } +} + +func TestLingeringIsRefusedWhereTheServiceManagerHasNoAccounts(t *testing.T) { + alpine, err := system.For("alpine") + if err != nil { + t.Fatal(err) + } + a := newAccount(t, false) + _, _, err = Apply(context.Background(), alpine, parse(t, declaring( + `{"id":"ops.login","type":"user","name":"ops","linger":true}`)), store.State{}, + store.OriginDeclared, a.run, nil, nil) + if err == nil || !strings.Contains(err.Error(), "linger") { + t.Fatalf("not refused: %v", err) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index ccccc76..f64c02d 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -374,6 +374,15 @@ type User struct { // Home directory. Absent means the system's default for a new user, and is not changed for // one that exists — moving somebody's home is not something a declaration should do quietly. Home string `json:"home,omitempty"` + + // Linger is whether the account's own service manager runs with nobody logged in (novox/hq ADR + // 0177). A user-scoped unit lives in that manager, and the manager runs only from the account's + // first login to its last logout — so a server's user unit, where nobody ever logs in, never + // runs without it, and a workstation's runs only while its person is there, which on a desktop + // is what is wanted. Absent asserts nothing, as Shell's does: true has the account linger, false + // has it not. On the account rather than on the unit, because it is the account's: two units of + // one account cannot disagree about it, and undeclaring one of them must not stop the other. + Linger *bool `json:"linger,omitempty"` } // Network is a named network on this machine. @@ -710,6 +719,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 @@ -755,11 +774,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..fdce635 --- /dev/null +++ b/internal/declaration/userscope_test.go @@ -0,0 +1,49 @@ +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) + } + } +} + +// novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted. +func TestAnAccountMayBeDeclaredToLinger(t *testing.T) { + d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`)) + if err != nil { + t.Fatal(err) + } + u, ok := d.Resources[0].(*User) + if !ok || u.Linger == nil || !*u.Linger { + t.Fatalf("linger not read: %+v", d.Resources[0]) + } + d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops"}]}`)) + if err != nil { + t.Fatal(err) + } + if u := d.Resources[0].(*User); u.Linger != nil { + t.Fatal("an account that said nothing about lingering asserts it") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index cdb9cb7..83ff255 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 @@ -102,6 +106,11 @@ type Applied struct { // host kept it — then the shell is left exactly as it is. Shell *LoginShell `json:"shell,omitempty"` + // Linger is, for a user, whether the account lingered before the mesh first changed it, and + // what the mesh set (novox/hq ADR 0177). Removal gives the found one back while the account + // still has the mesh's; absent when the mesh never changed it. + Linger *Lingering `json:"linger,omitempty"` + // Unpacked is, for an archive, what it put on the machine (novox/hq issue 162): the files and // directories it unpacked, and whether the directory it was unpacked into and the parents above // it were made by the host. Removal takes away exactly that and nothing else. Absent on a @@ -625,6 +634,16 @@ type LoginShell struct { Created bool `json:"created,omitempty"` } +// Lingering is what the host knows about whether an account's manager runs with nobody logged in, +// to give it back (novox/hq ADR 0177). +type Lingering struct { + // Found is whether it lingered when the mesh first changed it. Never overwritten by a later + // change, as LoginShell.Found is not. + Found bool `json:"found"` + // Set is what the mesh set last. + Set bool `json:"set"` +} + // Unpacked is what an archive put under its directory, so undeclaring it takes away exactly that // (novox/hq issue 162). type Unpacked struct { diff --git a/internal/system/arch.go b/internal/system/arch.go index 6c32a27..adbf383 100644 --- a/internal/system/arch.go +++ b/internal/system/arch.go @@ -2,6 +2,7 @@ package system import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -186,7 +187,7 @@ func describeAge(when, now time.Time) string { // so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC // would have had to drop. func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) { - out, _ := run(ctx, "systemctl", "show", unit, + out, asked := run(ctx, "systemctl", "show", unit, "--property=LoadState", "--property=ActiveState", "--property=Type", "--property=RemainAfterExit", "--property=ExecMainStatus") @@ -212,6 +213,11 @@ func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, switch load { case "": + // With why, where it said why: an account's manager that could not be reached says so + // here, and nowhere else (novox/hq ADR 0177). + if asked != nil { + return "", fmt.Errorf("the service manager said nothing about %s: %w", unit, asked) + } return "", fmt.Errorf("the service manager said nothing about %s", unit) case "not-found": return "", fmt.Errorf( @@ -364,3 +370,82 @@ func (arch) ReloadService(ctx context.Context, run Runner, unit string) error { _, err := run(ctx, "systemctl", "reload", unit) return err } + +// An account's own service manager (novox/hq ADR 0177). +// +// systemd runs one manager per account beside the machine's, as user@.service, from the +// account's first login until its last logout — or for as long as the machine runs, when the +// account lingers. A user-scoped unit lives in that manager, so it can be acted on only while the +// manager runs, and lingering is what makes it run with nobody logged in. + +// lingerDir is where systemd-logind keeps which accounts linger: one empty file per account. A +// variable so a test can give it a directory of its own; LingerIn is how. +var lingerDir = "/var/lib/systemd/linger" + +// LingerIn points where the machine keeps lingering at a directory a test owns, until the returned +// function puts it back. Nothing outside a test calls it. +func LingerIn(dir string) (restore func()) { + was := lingerDir + lingerDir = dir + return func() { lingerDir = was } +} + +// Lingering is whether an account's manager is kept running with nobody logged in. Read from +// logind's own record rather than from `loginctl show-user`, which answers only for an account +// that is logged in or already lingers — absence there is an error, and absence here is the answer. +func (arch) Lingering(_ context.Context, _ Runner, name string) (bool, error) { + _, err := os.Stat(filepath.Join(lingerDir, name)) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("whether %q lingers could not be read: %w", name, err) +} + +// SetLingering has logind keep an account's manager running with nobody logged in, or stop doing +// so. Disabling it stops the manager of an account that is not logged in, and every unit in it. +func (arch) SetLingering(ctx context.Context, run Runner, name string, on bool) error { + verb := "enable-linger" + if !on { + verb = "disable-linger" + } + if _, err := run(ctx, "loginctl", verb, name); err != nil { + return fmt.Errorf("cannot %s for %q: %w", verb, name, err) + } + return nil +} + +// UserManagerRunning is whether an account's own manager runs now, asked of the machine's manager +// — never of the account's. +// +// **Asking the account's manager would start it.** `systemctl --user --machine=@` reaches +// it through `systemd-run --machine=@.host -p PAMName=login systemd-stdio-bridge`: a login, +// which starts the account's manager if none runs, for as long as that one command takes. The host +// would then act on a manager that ends a moment later and take the account's whole session with it +// — a unit started into it stops again, and the next apply starts it again. So whether there is a +// manager is read from user@.service in the machine's own, which a query does not start. +// +// exists is false for an account the machine does not have. +func (arch) UserManagerRunning(ctx context.Context, run Runner, account string) (running, exists bool, err error) { + login, exists, err := LookUpUser(ctx, run, account) + if err != nil || !exists { + return false, exists, err + } + if login.UID == "" { + return false, true, fmt.Errorf("the user database gave %q no number", account) + } + // is-active exits non-zero for every answer but "active", so the words are the answer and the + // exit is not; no words at all is a manager that did not answer. + out, err := run(ctx, "systemctl", "is-active", "user@"+login.UID+".service") + state := strings.TrimSpace(out) + if state == "" { + if err == nil { + err = errors.New("no answer") + } + return false, true, fmt.Errorf("the service manager did not say whether %q's own manager runs: %w", + account, err) + } + return state == "active", true, nil +} diff --git a/internal/system/system.go b/internal/system/system.go index 7669917..cfd7fa6 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -84,6 +84,9 @@ type System interface { type Login struct { Home string Shell string + // UID is the account's number, as the user database gives it: what names the account's own + // service manager to the machine's (user@.service, novox/hq ADR 0177). + UID string } // LookUpUser reads a login from the user database. @@ -115,7 +118,7 @@ func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, erro return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry", strings.TrimSpace(out), name) } - return Login{Home: fields[5], Shell: fields[6]}, true, nil + return Login{Home: fields[5], Shell: fields[6], UID: fields[2]}, true, nil } // GroupsOf is every group a login is in. diff --git a/internal/system/usermanager_test.go b/internal/system/usermanager_test.go new file mode 100644 index 0000000..0eeb29f --- /dev/null +++ b/internal/system/usermanager_test.go @@ -0,0 +1,75 @@ +package system + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// novox/hq ADR 0177: whether an account's own manager runs is asked of the machine's manager, by +// the account's number — never of the account's, which the question would start. +func TestAnAccountsManagerIsAskedOfTheMachinesManager(t *testing.T) { + var asked []string + up := false + run := func(_ context.Context, name string, args ...string) (string, error) { + line := name + " " + strings.Join(args, " ") + asked = append(asked, line) + switch { + case line == "getent passwd ops": + return "ops:x:1001:1001::/home/ops:/bin/bash\n", nil + case name == "getent": + return "", errors.New("getent exited 2: ") + case line == "systemctl is-active user@1001.service": + if up { + return "active\n", nil + } + return "inactive\n", errors.New("systemctl exited 3: ") + } + t.Fatalf("asked %q", line) + return "", nil + } + a := arch{} + for _, want := range []bool{false, true} { + up = want + running, exists, err := a.UserManagerRunning(context.Background(), run, "ops") + if err != nil || !exists || running != want { + t.Fatalf("up %v: running %v exists %v err %v", want, running, exists, err) + } + } + if _, exists, err := a.UserManagerRunning(context.Background(), run, "nobody-here"); err != nil || exists { + t.Fatalf("an account the machine does not have: exists %v err %v", exists, err) + } + for _, c := range asked { + if strings.Contains(c, "--user") || strings.Contains(c, "--machine") { + t.Fatalf("the account's own manager was asked: %s", c) + } + } +} + +func TestLingeringIsReadFromLogindsRecordAndSetThroughLoginctl(t *testing.T) { + dir := t.TempDir() + defer LingerIn(dir)() + a := arch{} + if on, err := a.Lingering(context.Background(), nil, "ops"); err != nil || on { + t.Fatalf("no record read as lingering: %v %v", on, err) + } + if err := os.WriteFile(filepath.Join(dir, "ops"), nil, 0o644); err != nil { + t.Fatal(err) + } + if on, err := a.Lingering(context.Background(), nil, "ops"); err != nil || !on { + t.Fatalf("logind's record not read as lingering: %v %v", on, err) + } + var asked []string + run := func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + return "", nil + } + _ = a.SetLingering(context.Background(), run, "ops", true) + _ = a.SetLingering(context.Background(), run, "ops", false) + if strings.Join(asked, "; ") != "loginctl enable-linger ops; loginctl disable-linger ops" { + t.Fatalf("%v", asked) + } +}