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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "":
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user