Files
mesh-host/internal/units/units.go
T
jschoubben 62af89f85d
mesh/merge-gate head 62af89f85d0d merged into trunk 1a2677882380 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 62af89f85d0d merged into trunk 1a2677882380 — pass: its merge-check.sh passed
mesh/delivery delivered
Start no account's manager to look at its units (hq issue 764)
Every 15 s the failed-units look read the own manager of every account
the declaration creates, and reading one is a login that starts it: six
module accounts' managers on the control node started and stopped all
day, most of its journal. A look only reads (ADR 0240 rule 6) and never
asks an account's manager (ADR 0177).

An account's manager is read now only when the declaration places a
user-scoped unit in it, and only after the machine's own manager says
user@<uid>.service is active, as the apply asks before it. A manager not
running is said away in the units statement, with no failed units; in
liveness its units read unknown with the apply's words, never down. The
operator's account on a desktop is still read while logged in, since a
module places a user unit there (issue 315's Razer daemon stays said).
2026-10-15 00:33:34 +02:00

439 lines
14 KiB
Go

// Package units is the node-engine reading which units its machine's service managers say failed, and
// whose each is (novox/hq issue 315, under ADR 0240 rule 1 and ADR 0241 §3).
//
// **A failed unit of a mesh module was never raised.** Liveness judges what a module runs long-lived — a
// container, a process, a service stated `running` — and nothing else. A module that installs a daemon as
// a package, whose unit the package ships and D-Bus activation starts, declares no service: its unit
// failed at every start and the machine read healthy, while the profile's `service-manager` said
// `degraded` and nothing raised that either. Two network mounts the operator wrote into the machine's
// 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 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;
// 3. a package the declaration installs, when the unit's file belongs to it — asked of the package
// manager, once per unit file;
//
// and otherwise nobody's in the mesh: the machine's. A unit liveness already judges is left to it, so a
// failure is said once. **The two-look rule is the engine's** (ADR 0241 §2): a unit is said failed on
// its second look in a row, and no longer on its first look not failed.
//
// **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 (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// The managers a unit is in.
const (
ScopeSystem = declaration.ScopeSystem
ScopeUser = declaration.ScopeUser
)
// How a unit's owner was found.
const (
ViaUnit = "unit"
ViaFile = "file"
ViaPackage = "package"
)
// The machine's service managers, as the statement says them.
const (
// Running is every manager read and no unit failed.
Running = "running"
// Degraded is a unit failed in a manager read.
Degraded = "degraded"
// Unknown is no manager could be read.
Unknown = "unknown"
)
// owner is a module and the id of its resource that places a unit.
type owner struct{ module, resource string }
// Owned is what a declaration places on the machine, as the reading needs it: the units it states, the
// files and packages it puts there, and the accounts whose managers it places units in.
type Owned struct {
// Units is keyed by manager and unit (key).
Units map[string]owner
// Judged is every unit liveness judges, by the same key: left to it.
Judged map[string]bool
// 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: 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
}
// key is a unit in one manager: "system/<unit>", or "user:<account>/<unit>".
func key(scope, user, unit string) string {
if scope == ScopeUser {
return "user:" + user + "/" + unit
}
return "system/" + unit
}
// OwnedBy reads what a declaration places. held is every resource an adopted machine holds as found,
// by id — the machine's, not a module's. A resource the mesh declares in its own right (no module) names
// no module: its failed unit is said with the machine's, under the resource's id.
func OwnedBy(d *declaration.Declaration, held map[string]bool) Owned {
o := Owned{Units: map[string]owner{}, Judged: map[string]bool{}, Files: map[string]owner{},
Packages: map[string]owner{}}
if d == nil {
return o
}
accounts := map[string]bool{}
for _, r := range d.Resources {
if held[r.Identity()] || strings.HasPrefix(r.Identity(), declaration.AdoptionPrefix) {
continue
}
own := ownerOf(r.Identity())
switch v := r.(type) {
case *declaration.Service:
scope, user := ScopeSystem, ""
if v.UserScoped() {
scope, user = ScopeUser, v.User
accounts[v.User] = true
}
k := key(scope, user, v.Unit)
o.Units[k] = own
if v.State == "running" {
o.Judged[k] = true
}
case *declaration.Process:
k := key(ScopeSystem, "", v.Name+".service")
o.Units[k] = own
if !v.RunOnce && v.Schedule == "" {
o.Judged[k] = true
}
case *declaration.File:
o.Files[v.Path] = own
case *declaration.Package:
if !v.Absent {
o.Packages[v.Package] = own
}
}
}
for a := range accounts {
if a != "" {
o.Accounts = append(o.Accounts, a)
}
}
sort.Strings(o.Accounts)
return o
}
// ownerOf is a resource id's module and the id itself: everything before the last dot is the module (as
// liveness.ModuleOf reads it); an id with no dot is the mesh's own, and names no module.
func ownerOf(id string) owner {
at := strings.LastIndex(id, ".")
if at <= 0 {
return owner{resource: id}
}
return owner{module: id[:at], resource: id}
}
// Listed is one failed unit as its manager lists it.
type Listed struct {
Unit string
// Load is loaded, not-found, masked, bad-setting…
Load string
}
// Shown is what the manager says of one unit's file and how it failed.
type Shown struct {
FragmentPath string
// Result is how it failed: exit-code, timeout, start-limit-hit…
Result string
}
// Reader is what a look asks the machine. An error is "could not be read": that manager is said unread,
// never healthy and never failed.
type Reader interface {
// Failed is every unit in failed state in a manager: the machine's (user empty), or an account's.
Failed(ctx context.Context, scope, user string) ([]Listed, error)
// Show is each unit's file and result, in a manager.
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.
type Failed struct {
Unit string
Scope string
// User is the account whose manager it is in, for a user unit: evidence inside the mesh.
User string
Load string
Result string
// Module and Resource own it; empty when no module does. Via is how that was found.
Module string
Resource string
Via string
// Since is the first look that found it failed; Streak the looks in a row since.
Since time.Time
Streak int
}
// Statement is one look at every manager.
type Statement struct {
At time.Time
// State is the worst of the managers read: running, degraded, or unknown when none was.
State string
// Failed is every unit failed on two looks in a row and not judged by liveness: the modules' and the
// machine's.
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.
func (s Statement) Owned() []Failed { return s.filter(true) }
func (s Statement) Unowned() []Failed { return s.filter(false) }
func (s Statement) filter(owned bool) []Failed {
var out []Failed
for _, f := range s.Failed {
if (f.Module != "") == owned {
out = append(out, f)
}
}
return out
}
// seen is what the judge keeps of one failed unit between looks.
type seen struct {
since time.Time
streak int
}
// Judge reads the machine's failed units on every look. Safe for the apply and the looking loop at once.
type Judge struct {
reader Reader
Now func() time.Time
mu sync.Mutex
owned Owned
// known says a declaration was set: before it, nothing is read — every unit would read as the
// machine's, and then as a module's a moment later.
known bool
seen map[string]*seen
owners map[string]ownerAnswer
said string
last Statement
}
// ownerAnswer is the package manager's answer for one unit file, kept until the declaration changes.
type ownerAnswer struct {
pkg string
ok bool
}
// New is a judge reading through r.
func New(r Reader) *Judge {
return &Judge{reader: r, Now: time.Now, seen: map[string]*seen{}, owners: map[string]ownerAnswer{}}
}
// Set is what the declaration just applied places. The package manager is asked afresh after it, since a
// package installed or removed changes whose a unit file is.
func (j *Judge) Set(o Owned) {
j.mu.Lock()
defer j.mu.Unlock()
j.owned, j.known = o, true
j.owners = map[string]ownerAnswer{}
}
// Last is the statement of the last look.
func (j *Judge) Last() Statement {
j.mu.Lock()
defer j.mu.Unlock()
return j.last
}
// manager is one service manager read.
type manager struct{ scope, user string }
func (m manager) String() string {
if m.scope == ScopeUser {
return "the account manager of " + m.user
}
return "the machine's service manager"
}
// Look reads every manager once and answers the statement, and whether what it says changed since the
// last look. Before a declaration is set it reads nothing and answers an empty statement, which says
// nothing of the units.
func (j *Judge) Look(ctx context.Context) (Statement, bool) {
j.mu.Lock()
defer j.mu.Unlock()
if !j.known {
return Statement{}, false
}
now := j.Now()
managers := []manager{{scope: ScopeSystem}}
for _, a := range j.owned.Accounts {
managers = append(managers, manager{scope: ScopeUser, user: a})
}
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())))
keep(m)
continue
}
read++
if st.State == Unknown {
st.State = Running
}
if len(listed) > 0 {
st.State = Degraded
}
var names []string
for _, l := range listed {
names = append(names, l.Unit)
}
var shown map[string]Shown
if len(names) > 0 {
if shown, err = j.reader.Show(ctx, m.scope, m.user, names); err != nil {
shown = map[string]Shown{}
}
}
for _, l := range listed {
k := key(m.scope, m.user, l.Unit)
failedNow[k] = true
s := j.seen[k]
if s == nil {
s = &seen{since: now}
j.seen[k] = s
}
s.streak++
if j.owned.Judged[k] || s.streak < 2 {
continue
}
f := Failed{Unit: l.Unit, Scope: m.scope, User: m.user, Load: l.Load, Result: shown[l.Unit].Result,
Since: s.since, Streak: s.streak}
if own, via, ok := j.ownerOfUnit(ctx, k, shown[l.Unit].FragmentPath); ok {
f.Module, f.Resource, f.Via = own.module, own.resource, via
}
st.Failed = append(st.Failed, f)
}
}
for k := range j.seen {
if !failedNow[k] {
delete(j.seen, k)
}
}
sort.Slice(st.Failed, func(a, b int) bool {
if st.Failed[a].Scope != st.Failed[b].Scope {
return st.Failed[a].Scope < st.Failed[b].Scope
}
return st.Failed[a].Unit < st.Failed[b].Unit
})
if read == 0 {
st.State = Unknown
}
word := st.State + "|away:" + strings.Join(st.Away, ",")
for _, f := range st.Failed {
word += "|" + f.Scope + "/" + f.Unit + "/" + f.Module
}
changed := word != j.said
j.said, j.last = word, st
return st, changed
}
// ownerOfUnit is whose a failed unit is: the declaration's unit, then the file the unit is, then the
// package the file belongs to.
func (j *Judge) ownerOfUnit(ctx context.Context, k, fragment string) (owner, string, bool) {
if o, ok := j.owned.Units[k]; ok {
return o, ViaUnit, true
}
if fragment == "" {
return owner{}, "", false
}
if o, ok := j.owned.Files[fragment]; ok {
return o, ViaFile, true
}
if len(j.owned.Packages) == 0 {
return owner{}, "", false
}
a, asked := j.owners[fragment]
if !asked {
pkg, ok, err := j.reader.PackageOwning(ctx, fragment)
if err != nil {
// Not kept: asked again on the next look.
return owner{}, "", false
}
a = ownerAnswer{pkg: pkg, ok: ok}
j.owners[fragment] = a
}
if !a.ok {
return owner{}, "", false
}
if o, ok := j.owned.Packages[a.pkg]; ok {
return o, ViaPackage, true
}
return owner{}, "", false
}
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}