Start no account's manager to look at its units (hq issue 764) #112

Merged
mesh-admin merged 1 commits from fix/764-a-look-starts-no-user-manager into main 2026-10-14 22:38:03 +00:00
8 changed files with 276 additions and 17 deletions
+3 -2
View File
@@ -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,
+2
View File
@@ -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.
+53
View File
@@ -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)
}
}
+20 -1
View File
@@ -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...)...)
+21
View File
@@ -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{}}
+12 -2
View File
@@ -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)
}
+48 -12
View File
@@ -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@<uid>.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
}
+117
View File
@@ -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)
}
}
}