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:
+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