diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index eb779bd..9591e7e 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1130,7 +1130,7 @@ func runLink(ctx context.Context, opts options) error { // The one liveness judge on this machine (novox/hq ADR 0240), made before anything applies: every // report this host makes carries what it says. if j, err := liveness.Open(filepath.Join(filepath.Dir(opts.state), liveness.FileName), - &liveness.Exec{Run: apply.ExecRunner}); j != nil { + &liveness.Exec{Run: apply.ExecRunner, System: builtFor}); j != nil { if err != nil { say(err.Error()) } @@ -1794,7 +1794,8 @@ func withUnits(h *link.Health, us *units.Statement) *link.Health { if us == nil || us.State == "" { return h } - h.Units = &link.UnitsHealth{State: us.State, Failed: []link.FailedUnit{}, Unread: us.Unread} + h.Units = &link.UnitsHealth{State: us.State, Failed: []link.FailedUnit{}, Unread: us.Unread, + Away: us.Away} for _, f := range us.Failed { if f.Module == "" { h.Units.Failed = append(h.Units.Failed, link.FailedUnit{Unit: f.Unit, Scope: f.Scope, Load: f.Load, diff --git a/internal/link/messages.go b/internal/link/messages.go index 0dc192e..96379af 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -359,6 +359,8 @@ type UnitsHealth struct { Failed []FailedUnit `json:"failed"` // Unread names a manager that could not be read, with why. Unread []string `json:"unread,omitempty"` + // Away names an account's own manager not running, so not read: nothing in it is failed (issue 764). + Away []string `json:"away,omitempty"` } // FailedUnit is one failed unit no module places. diff --git a/internal/liveness/liveness_test.go b/internal/liveness/liveness_test.go index b304706..aa39729 100644 --- a/internal/liveness/liveness_test.go +++ b/internal/liveness/liveness_test.go @@ -330,3 +330,56 @@ func TestOneLookIsOneReadOfEveryContainer(t *testing.T) { t.Fatalf("a container the inspect found, beside one it did not: %+v", s) } } + +// **A look starts no account's manager** (novox/hq issue 764, ADR 0177): a user-scoped unit's manager is +// asked of the machine's first; one not running is not read, nothing is started, and its unit is said +// unknown with why — away, never down; one running is read as before. +func TestAUserUnitsManagerNotRunningIsNotReadAndNotDown(t *testing.T) { + var asked []string + manager := "inactive" + run := func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + switch { + case name == "getent": + return "operator:x:1000:1000::/home/operator:/bin/zsh\n", nil + case name == "systemctl" && len(args) > 0 && args[0] == "is-active": + if manager != "active" { + return manager + "\n", errors.New("systemctl exited 3") + } + return "active\n", nil + case name == "systemctl": + return "Id=dunst.service\nLoadState=loaded\nActiveState=active\nSubState=running\nNRestarts=0\nInvocationID=i1\n", nil + } + return "", errors.New("not a command the judge may run") + } + c := &clock{now: t0} + j := aJudge(t, &Exec{Run: run, System: "arch"}, c, "") + j.Set([]Resource{{Module: "dunst", ID: "dunst.daemon", Kind: KindService, Target: "dunst.service", + Scope: "user", User: "operator"}}) + var st Statement + for i := 0; i < 4; i++ { + c.now = t0.Add(time.Duration(i) * time.Minute) + st, _ = j.Look(t.Context()) + } + if s := stateOf(t, st, "dunst.daemon"); s.State != Unknown || !strings.Contains(s.Reason, "not logged in") { + t.Fatalf("a unit in a manager not running is away, not down: %+v", s) + } + for _, a := range asked { + if strings.Contains(a, "--machine=") { + t.Fatalf("the account's own manager was asked while it was not running: %q", a) + } + if a != "getent passwd operator" && a != "systemctl is-active user@1000.service" { + t.Errorf("asked something but whether the manager runs: %q", a) + } + } + // Logged in: read in the account's own manager, as before. + manager, asked = "active", nil + c.now = t0.Add(5 * time.Minute) + st, _ = j.Look(t.Context()) + if s := stateOf(t, st, "dunst.daemon"); s.State == Unknown || s.State == Unhealthy { + t.Fatalf("a unit in a running manager is read: %+v", s) + } + if last := asked[len(asked)-1]; !strings.HasPrefix(last, "systemctl --user --machine=operator@ show ") { + t.Fatalf("the running manager is read in the account's own: %q", last) + } +} diff --git a/internal/liveness/runtime.go b/internal/liveness/runtime.go index eccdc5a..93d101a 100644 --- a/internal/liveness/runtime.go +++ b/internal/liveness/runtime.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "sync" + + "github.com/novox/mesh-host/internal/system" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner), so a look @@ -16,9 +18,13 @@ type Runner func(ctx context.Context, name string, args ...string) (string, erro // Exec reads the container runtime and the service manager through their command lines: one inspect of // every container, one show of every unit per manager. **Reads only** — the whole of what this file may -// ask either of them is `container inspect`, `version` and `show` (ADR 0240 rule 6, and a test holds it). +// ask either of them is `container inspect`, `version` and `show`, and of the machine's manager whether an +// account's own is active (ADR 0240 rule 6, and a test holds it). type Exec struct { Run Runner + // System is what the host was built for: it says how the machine's manager is asked whether an + // account's own runs (system.ManagerRunning). + System string mu sync.Mutex // cri is the runtime that answered, once one has. @@ -146,6 +152,19 @@ func (e *Exec) Units(ctx context.Context, scope, user string, units []string) (m } args := []string{"show", "--property=" + unitProperties, "--"} if scope == "user" && user != "" { + // **A look starts no account's manager** (novox/hq issue 764, ADR 0177): reading it is a login, + // which starts it when none runs. The machine's manager is asked first; a manager not running is + // away — its units unread, said unknown with why, never down — as the apply says them waiting. + running, exists, err := system.ManagerRunning(ctx, e.System, system.Runner(e.Run), user) + switch { + case err != nil: + return nil, fmt.Errorf("whether %s's own service manager runs could not be read: %w", user, err) + case !exists: + return nil, fmt.Errorf("the machine has no account %q, whose own manager these units are in", user) + case !running: + return nil, fmt.Errorf("%s's own service manager is not running: the account is not logged in "+ + "and does not linger", user) + } args = append([]string{"--user", "--machine=" + user + "@"}, args...) } said, err := e.Run(ctx, "systemctl", append(args, units...)...) diff --git a/internal/system/system.go b/internal/system/system.go index e0e8435..e361dff 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -304,6 +304,27 @@ func For(name string) (System, error) { name, strings.Join(names, ", ")) } +// userManagers is a service manager that runs a manager per account (novox/hq ADR 0177). +type userManagers interface { + UserManagerRunning(ctx context.Context, run Runner, account string) (running, exists bool, err error) +} + +// ManagerRunning is whether account's own manager runs on a machine built for name, asked of the +// machine's manager through run — never of the account's, which a question would start (see +// arch.UserManagerRunning). A look asks it before it reads anything in an account's manager (novox/hq +// issue 764). A system with no manager per account is an error: nothing can be read there. +func ManagerRunning(ctx context.Context, name string, run Runner, account string) (running, exists bool, err error) { + sys, err := For(name) + if err != nil { + return false, false, err + } + um, ok := sys.(userManagers) + if !ok { + return false, false, fmt.Errorf("this machine's service manager (%s) has no manager per account", sys.Name()) + } + return um.UserManagerRunning(ctx, run, account) +} + // All is every system the host can be built for. func All() []System { return []System{arch{}, alpine{}, android{}} diff --git a/internal/units/exec.go b/internal/units/exec.go index 8d8f73e..91c29a8 100644 --- a/internal/units/exec.go +++ b/internal/units/exec.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "github.com/novox/mesh-host/internal/system" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner), so a look reads @@ -12,8 +14,9 @@ import ( type Runner func(ctx context.Context, name string, args ...string) (string, error) // Exec reads the service managers through systemctl and the package database through the system's own -// owner query. **Reads only**: `list-units`, `show` and the owner query are the whole of what it asks -// (ADR 0240 rule 6, and a test holds it). +// owner query. **Reads only**: `list-units`, `show`, the owner query, and — of the machine's manager and +// user database — whether an account's manager is active, are the whole of what it asks (ADR 0240 rule 6, +// and a test holds it). type Exec struct { Run Runner // System is what the host was built for; it says how a file's package is asked. A system with no @@ -103,3 +106,10 @@ func (e Exec) PackageOwning(ctx context.Context, path string) (string, bool, err } return "", false, nil } + +// UserManagerRunning asks the machine's manager whether an account's own runs, as the apply does before it +// touches a user-scoped unit (system.UserManagerRunning; novox/hq ADR 0177, issue 764). A system with no +// manager per account cannot say, and is an error: that manager is said unread. +func (e Exec) UserManagerRunning(ctx context.Context, account string) (running, exists bool, err error) { + return system.ManagerRunning(ctx, e.System, system.Runner(e.Run), account) +} diff --git a/internal/units/units.go b/internal/units/units.go index f796b5f..3abb2be 100644 --- a/internal/units/units.go +++ b/internal/units/units.go @@ -9,8 +9,8 @@ // own mount table, and a unit a removed package left behind, failed beside it, said by nobody. // // On every look the engine asks each service manager the mesh places units in — the machine's, and the -// account manager of every account the declaration names — which units failed, and says each one's -// owner: +// account manager of every account the declaration places a user-scoped unit for — which units failed, +// and says each one's owner: // // 1. a service or process the declaration states, by its unit and manager; // 2. a file the declaration writes, when the unit's file is that file; @@ -23,6 +23,12 @@ // // **It reads; it never acts** (ADR 0240 rule 6): `list-units`, `show` and the package manager's owner // query are the whole of what it asks. It neither resets nor restarts anything. +// +// **A look starts no account's manager** (novox/hq issue 764, ADR 0177). Reading an account's manager is +// a login, and a login starts the manager when none runs; so the machine's own manager is asked first +// whether user@.service is active, and the account's is read only then. A manager that is not +// running is away: no unit in it is failed, none is read, and the statement says it away — the apply's +// word for the same absence ("waiting" for its manager). package units import ( @@ -72,7 +78,9 @@ type Owned struct { // Files is keyed by path; Packages by package name, only those declared present. Files map[string]owner Packages map[string]owner - // Accounts are the accounts whose own managers are read, sorted. + // Accounts are the accounts whose own managers are read, sorted: each one the declaration places a + // user-scoped unit for, and no other (novox/hq issue 764). An account the declaration only creates + // has no unit of the mesh's in its manager to fail, and asking that manager starts it. Accounts []string } @@ -123,8 +131,6 @@ func OwnedBy(d *declaration.Declaration, held map[string]bool) Owned { if !v.Absent { o.Packages[v.Package] = own } - case *declaration.User: - accounts[v.Name] = true } } for a := range accounts { @@ -169,6 +175,10 @@ type Reader interface { Show(ctx context.Context, scope, user string, units []string) (map[string]Shown, error) // PackageOwning is the package a file belongs to; false when none does or the machine cannot say. PackageOwning(ctx context.Context, path string) (string, bool, error) + // UserManagerRunning is whether an account's own manager runs now, asked of the machine's manager + // and never of the account's, which a question would start (novox/hq issue 764); exists is false + // for an account the machine does not have. + UserManagerRunning(ctx context.Context, account string) (running, exists bool, err error) } // Failed is one failed unit as the statement says it. @@ -198,6 +208,9 @@ type Statement struct { Failed []Failed // Unread names each manager that could not be read, with why. Unread []string + // Away names each account manager not running, so not read: its account is not logged in and does + // not linger. Nothing in it is failed (novox/hq issue 764). + Away []string } // Owned answers the failures a module owns; Unowned those no module does. @@ -290,16 +303,39 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) { st := Statement{At: now, State: Unknown} read := 0 failedNow := map[string]bool{} + keep := func(m manager) { + // What it held is neither cleared nor counted while it cannot be read. + for k := range j.seen { + if strings.HasPrefix(k, key(m.scope, m.user, "")) { + failedNow[k] = true + } + } + } for _, m := range managers { + if m.scope == ScopeUser { + // Asked of the machine's manager first: reading the account's own would start it. + running, exists, err := j.reader.UserManagerRunning(ctx, m.user) + switch { + case err != nil: + st.Unread = append(st.Unread, fmt.Sprintf("%s could not be read: %s", m, firstLine(err.Error()))) + keep(m) + continue + case !exists: + st.Unread = append(st.Unread, fmt.Sprintf("%s could not be read: the machine has no account %q", + m, m.user)) + keep(m) + continue + case !running: + // Away: nothing in it runs, so nothing in it is failed; what it held is cleared. + st.Away = append(st.Away, m.user+"'s own service manager is not running: the account is not "+ + "logged in and does not linger, so its units are not read") + continue + } + } listed, err := j.reader.Failed(ctx, m.scope, m.user) if err != nil { st.Unread = append(st.Unread, fmt.Sprintf("%s could not be read: %s", m, firstLine(err.Error()))) - // What it held is neither cleared nor counted while it cannot be read. - for k := range j.seen { - if strings.HasPrefix(k, key(m.scope, m.user, "")) { - failedNow[k] = true - } - } + keep(m) continue } read++ @@ -353,7 +389,7 @@ func (j *Judge) Look(ctx context.Context) (Statement, bool) { if read == 0 { st.State = Unknown } - word := st.State + word := st.State + "|away:" + strings.Join(st.Away, ",") for _, f := range st.Failed { word += "|" + f.Scope + "/" + f.Unit + "/" + f.Module } diff --git a/internal/units/units_test.go b/internal/units/units_test.go index 71f7cf5..e5b6adb 100644 --- a/internal/units/units_test.go +++ b/internal/units/units_test.go @@ -21,6 +21,11 @@ type fakeReader struct { owners map[string]string // by path unread map[string]error ownerQs int + // away is each account whose own manager is not running; noAccount each the machine lacks. + away map[string]bool + noAccount map[string]bool + // read is every manager whose failed units were asked, in order. + read []string } func mgr(scope, user string) string { @@ -31,6 +36,7 @@ func mgr(scope, user string) string { } func (f *fakeReader) Failed(_ context.Context, scope, user string) ([]Listed, error) { + f.read = append(f.read, mgr(scope, user)) if err := f.unread[mgr(scope, user)]; err != nil { return nil, err } @@ -45,6 +51,13 @@ func (f *fakeReader) Show(_ context.Context, _, _ string, units []string) (map[s return out, nil } +func (f *fakeReader) UserManagerRunning(_ context.Context, account string) (bool, bool, error) { + if f.noAccount[account] { + return false, false, nil + } + return !f.away[account], true, nil +} + func (f *fakeReader) PackageOwning(_ context.Context, path string) (string, bool, error) { f.ownerQs++ p, ok := f.owners[path] @@ -75,6 +88,11 @@ func declared() *declaration.Declaration { &declaration.Package{ID: "openrazer.daemon", Type: declaration.TypePackage, Package: "openrazer-daemon"}, &declaration.Package{ID: "clipmenu.greenclip", Type: declaration.TypePackage, Package: "rofi-greenclip", Absent: true}, &declaration.User{ID: "zsh.account", Type: declaration.TypeUser, Name: "operator"}, + // The operator's own manager is read because a module places a unit in it (dunst, on a desktop). + &declaration.Service{ID: "dunst.reload", Type: declaration.TypeService, Unit: "dunst.service", + Scope: declaration.ScopeUser, User: "operator"}, + // A module account the declaration only creates: its manager is never asked (issue 764). + &declaration.User{ID: "telegram.account", Type: declaration.TypeUser, Name: "telegram"}, &declaration.Service{ID: "sshd.run", Type: declaration.TypeService, Unit: "sshd.service", State: "running"}, &declaration.Service{ID: "power.after-boot", Type: declaration.TypeService, Unit: "mesh-power-after-boot.service"}, &declaration.File{ID: "watcher.unit", Type: declaration.TypeFile, Path: "/home/operator/.config/systemd/user/watcher.service"}, @@ -283,3 +301,102 @@ func TestNothingIsReadBeforeADeclaration(t *testing.T) { t.Fatalf("before a declaration every unit would read as the machine's: %+v", st) } } + +// Issue 764: a look starts no account's manager. Only an account the declaration places a user-scoped +// unit for is read at all, and its manager only while the machine's says it runs. + +func TestAnAccountWithNoUserUnitsIsNeverRead(t *testing.T) { + o := OwnedBy(declared(), nil) + if strings.Join(o.Accounts, " ") != "operator" { + t.Fatalf("only an account with a user-scoped unit is read: %v", o.Accounts) + } + r := shanks() + j := New(r) + j.Set(o) + lookTwice(t, j) + for _, m := range r.read { + if m == "user:telegram" { + t.Fatalf("a module account the declaration only creates had its manager read: %v", r.read) + } + } +} + +func TestAManagerNotRunningIsAwayAndNotRead(t *testing.T) { + r := shanks() + j := New(r) + j.Set(OwnedBy(declared(), nil)) + lookTwice(t, j) + if len(j.Last().Owned()) != 1 { + t.Fatalf("logged in, the Razer daemon is said: %+v", j.Last().Failed) + } + // The operator logs out: the manager is not asked, nothing in it is failed, it is said away. + r.away = map[string]bool{"operator": true} + r.read = nil + st, changed := j.Look(context.Background()) + if strings.Join(r.read, " ") != "system" { + t.Fatalf("a manager not running is not read: %v", r.read) + } + if !changed || len(st.Away) != 1 || !strings.Contains(st.Away[0], "operator") || len(st.Unread) != 0 { + t.Fatalf("away, said, and not unread: %+v", st) + } + for _, f := range st.Failed { + if f.Scope == ScopeUser { + t.Fatalf("a manager not running has no failed units: %+v", f) + } + } + if st.State != Degraded { + t.Fatalf("the machine's own failed mounts still say degraded: %q", st.State) + } + // Back in: read as before, and a failure is new again (two looks). + r.away = nil + st, _ = j.Look(context.Background()) + if len(st.Away) != 0 || len(st.Owned()) != 0 { + t.Fatalf("a manager running again is read, its failure on its first look not yet said: %+v", st) + } + st, _ = j.Look(context.Background()) + if len(st.Owned()) != 1 { + t.Fatalf("and said on the second: %+v", st.Failed) + } +} + +func TestAnAccountTheMachineLacksIsUnread(t *testing.T) { + r := shanks() + r.noAccount = map[string]bool{"operator": true} + j := New(r) + j.Set(OwnedBy(declared(), nil)) + st, _ := j.Look(context.Background()) + if strings.Join(r.read, " ") != "system" || len(st.Unread) != 1 { + t.Fatalf("an account the machine lacks is unread and its manager never asked: %v %+v", r.read, st) + } +} + +func TestWhetherAManagerRunsIsAskedOfTheMachines(t *testing.T) { + var asked []string + state := "inactive" + run := func(_ context.Context, name string, args ...string) (string, error) { + asked = append(asked, name+" "+strings.Join(args, " ")) + switch name { + case "getent": + return "telegram:x:10007:10007::/var/lib/telegram:/usr/bin/nologin\n", nil + case "systemctl": + if state != "active" { + return state + "\n", errors.New("systemctl exited 3") + } + return state + "\n", nil + } + return "", errors.New("unexpected") + } + e := Exec{Run: run, System: "arch"} + if running, exists, err := e.UserManagerRunning(context.Background(), "telegram"); running || !exists || err != nil { + t.Fatalf("an inactive manager is not running: %v %v %v", running, exists, err) + } + state = "active" + if running, _, err := e.UserManagerRunning(context.Background(), "telegram"); !running || err != nil { + t.Fatalf("an active manager runs: %v %v", running, err) + } + for _, a := range asked { + if a != "getent passwd telegram" && a != "systemctl is-active user@10007.service" { + t.Errorf("asked something but the user database and the machine's manager: %q", a) + } + } +}