A user unit waits for its account's manager, and lingering is the account's (hq ADR 0177)

An account's manager runs only while it is logged in or lingers. A user-scoped unit
whose manager is not running is now "waiting" rather than failed, its record kept as
it was; its removal is never fatal (kept recorded, retried) and an account that is
gone is forgotten. Whether the manager runs is asked of user@<uid>.service in the
machine's manager: asking the account's own, through --machine, logs it in.

The user shape gains `linger`, set with loginctl, read back from logind's record,
and given back on removal like the shell. Unit files the mesh writes under
~/.config/systemd/user or /etc/systemd/user make that unit the mesh's, and made,
holds and found units are keyed by manager and name, so an account's unit and the
machine's of one name are two units. A service moved between managers gives the old
one back through the manager it was in. OpenRC refuses both.
This commit is contained in:
jochen
2026-10-04 12:41:32 +02:00
parent 84540e709a
commit d5c365cb2b
13 changed files with 1148 additions and 83 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
+241 -15
View File
@@ -65,6 +65,9 @@ type Outcome struct {
// 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
@@ -88,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
}
}
@@ -228,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}
}
@@ -574,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())
@@ -598,6 +624,7 @@ func ApplyKeeping(
User: outcome.user,
Found: outcome.found,
Shell: outcome.shell,
Linger: outcome.linger,
Unpacked: outcome.unpacked,
Holds: holds(resource),
})
@@ -1130,9 +1157,56 @@ 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) {
run = managerFor(r.Scope, r.User, run)
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)
}
@@ -1157,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
}
@@ -1280,7 +1354,6 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
// a machine that uses another — and it reads the change when whatever starts it does.
func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
changed map[string]bool) (Outcome, error) {
run = managerFor(r.Scope, r.User, run)
out := begin(r)
out.scope, out.user = r.Scope, r.User
out.stateless = true
@@ -1403,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
@@ -2369,7 +2442,6 @@ func declaredDigest(r declaration.Resource) string {
// what was actually done — a unit already as it was found is forgotten, not "restored".
func removeService(ctx context.Context, sys system.System, a store.Applied, run Runner,
made bool) (string, string, error) {
run = managerFor(a.Scope, a.User, run)
if a.Stateless {
// Declared with no state (novox/hq ADR 0117): its lifecycle was never the mesh's, and the
// declaration that said so is the operator's word to hold to, a file of the mesh's or not.
@@ -2381,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"
@@ -2469,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
@@ -2507,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
@@ -2651,6 +2830,16 @@ func appliedIDs(applied []store.Applied) string {
// 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
@@ -2663,3 +2852,40 @@ func managerFor(scope, user string, run Runner) Runner {
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
+512 -53
View File
@@ -2,83 +2,178 @@ 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 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.
// accountManager is a user's service manager whose one unit starts when asked, recording the
// commands and refusing any that reach it outside the account's scope.
func accountManager(account string, commands *[]string) Runner {
active, enabled := false, false
return func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
*commands = append(*commands, line)
if name == "systemctl" && !strings.HasPrefix(line, "systemctl --user --machine="+account+"@ ") {
return "", fmt.Errorf("a system-scope command reached the account's manager: %s", line)
// 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
}
switch {
case strings.Contains(line, " start "):
active = true
return "", nil
case strings.Contains(line, " stop "):
active = false
return "", nil
case strings.Contains(line, " enable "):
enabled = true
return "", nil
case strings.Contains(line, " disable "):
enabled = false
return "", nil
case strings.Contains(line, "is-enabled"):
if enabled {
return "enabled", nil
}
return "disabled", nil
case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"):
if active {
return "LoadState=loaded\nActiveState=active\nSubState=running", nil
}
return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil
}
return 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) {
var commands []string
decl := `{"declaration":1,"resources":[
{"id":"i3.watcher","type":"service","unit":"i3-reload-watcher.service","state":"running","boot":"enabled","scope":"user","user":"ops"}
]}`
report, known, err := Apply(context.Background(), archHost(t), parse(t, decl),
store.State{}, store.OriginDeclared, accountManager("ops", &commands), nil, nil)
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(commands, "\n"))
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")
}
var started, enabled bool
for _, c := range commands {
if c == "systemctl --user --machine=ops@ start i3-reload-watcher.service" {
started = true
}
if c == "systemctl --user --machine=ops@ enable i3-reload-watcher.service" {
enabled = true
}
if !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 !started || !enabled {
t.Fatalf("the unit was not started and enabled in the account's manager:\n%s", strings.Join(commands, "\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" {
t.Fatalf("the record does not say whose manager the unit is in: %+v", recorded)
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)
}
}
@@ -92,8 +187,372 @@ func TestASystemUnitIsUntouchedByTheScope(t *testing.T) {
t.Fatal(err)
}
for _, c := range commands {
if strings.Contains(c, "--user") || strings.Contains(c, "--machine") {
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)
}
}
+9
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.
+19
View File
@@ -28,3 +28,22 @@ func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
}
}
}
// 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")
}
}
+15
View File
@@ -106,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
@@ -629,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)
}
}