Merge pull request 'User-scoped units (hq ADR 0177, to-be 38 WP6), with lingering and a manager that is not running' (#91) from feat/user-scoped-units into main

This commit is contained in:
2026-10-04 10:43:17 +00:00
13 changed files with 1286 additions and 27 deletions
+5
View File
@@ -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
+267 -12
View File
@@ -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 `<module>.<its own id>`, 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=<account>@`, 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
}
+2 -2
View File
@@ -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)
}
}
}
+62 -9
View File
@@ -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:
+1 -1
View File
@@ -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:
+117 -1
View File
@@ -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
+558
View File
@@ -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=<account>@` — 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)
}
}
+41
View File
@@ -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 != "":
+49
View File
@@ -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")
}
}
+19
View File
@@ -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 {
+86 -1
View File
@@ -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@<uid>.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=<account>@` reaches
// it through `systemd-run --machine=<account>@.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@<uid>.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
}
+4 -1
View File
@@ -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@<uid>.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.
+75
View File
@@ -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)
}
}