Files
mesh-host/internal/accounts/accounts.go
T
jochen ab4ca44f98
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/module-groups delivered: every member is delivered
Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)
A module puts the operator's account in a group by declaring the account with that group alone.
The node-engine now records each group it added, takes back only those when nothing declared still
asks for them, refuses a group the machine lacks before usermod runs, and states each such account
as its module's resource of kind account: relogin needed while the running session lacks the group.
2026-10-08 12:04:08 +02:00

238 lines
7.2 KiB
Go

// Package accounts is the node-engine reading whether an account has, where it runs, the groups a module
// put it in (novox/hq ADR 0252, issue 247).
//
// **A group takes effect at the next login.** The apply puts the account in a group in the machine's
// database at once. Every process already running keeps the groups it started with: the account's own
// service manager, and every unit it starts. The lighting daemon's module puts the operator's account in
// `openrazer`, and the daemon, started by that manager, still refuses to start, for as long as the
// session that began before lasts. Nothing said why.
//
// On every look the engine reads, for each account a module declares groups for:
//
// 1. whether the user database lists the account in each group. One it does not is the apply's to put
// right, and is said as `not in the group`;
// 2. whether the account's own service manager runs, and if it does, the groups that process holds,
// read from the process itself. A group the database lists and the running manager lacks is said as
// **relogin needed**, with what to do.
//
// No running manager is healthy: nobody is logged in, and the next login takes the groups.
//
// Each verdict is the declaring module's, as a resource of kind `account`, beside what liveness says: the
// controller raises it as the module's condition on two statements in a row, and clears it on the first
// healthy one.
//
// **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service
// manager's `show` of the account's manager unit, and that process's status file. It never starts the
// account's manager, which asking that manager itself would.
package accounts
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/declaration"
)
// The states, in the words liveness says them (the controller reads them alike).
const (
Healthy = "healthy"
Unhealthy = "unhealthy"
Unknown = "unknown"
)
// ReasonRelogin starts the reason of an account whose running session lacks a group it is in.
const ReasonRelogin = "relogin needed"
// Account is one user resource of a module that declares groups.
type Account struct {
Module string
// ID is the user resource's id; Name the account.
ID string
Name string
Groups []string
}
// Of is every account a declaration has a module put in a group. held is every resource an adopted
// machine holds as found, by id: its groups are not the mesh's yet. A resource the mesh declares in its
// own right (no module) is not judged here.
func Of(d *declaration.Declaration, held map[string]bool) []Account {
if d == nil {
return nil
}
var out []Account
for _, r := range d.Resources {
u, ok := r.(*declaration.User)
if !ok || len(u.Groups) == 0 || held[u.ID] || u.Name == "" {
continue
}
at := strings.LastIndex(u.ID, ".")
if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) {
continue
}
out = append(out, Account{Module: u.ID[:at], ID: u.ID, Name: u.Name,
Groups: append([]string(nil), u.Groups...)})
}
sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
return out
}
// Session is what an account's own service manager holds.
type Session struct {
// Running is whether the manager runs.
Running bool
// Has is, of the groups asked about, those the running manager holds.
Has map[string]bool
}
// Reader is what a look asks the machine. An error is "could not be read": said unknown, never healthy.
type Reader interface {
// InDatabase is every group the user database lists an account in.
InDatabase(ctx context.Context, account string) ([]string, error)
// Session is the account's own service manager: whether it runs, and which of groups it holds.
Session(ctx context.Context, account string, groups []string) (Session, error)
}
// Verdict is one account's state as a statement says it.
type Verdict struct {
Account
State string
Reason string
Since time.Time
Streak int
}
// Statement is one look at every account.
type Statement struct {
At time.Time
Accounts []Verdict
}
// Healthy says no account in it is anything but healthy.
func (s Statement) Healthy() bool {
for _, v := range s.Accounts {
if v.State != Healthy {
return false
}
}
return true
}
// Judge reads every account's groups 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
accounts []Account
kept map[string]*Verdict
last Statement
}
// New is a judge reading through r.
func New(r Reader) *Judge {
return &Judge{reader: r, Now: time.Now, kept: map[string]*Verdict{}}
}
// Set is what the declaration just applied asks. An account no longer asked for is forgotten.
func (j *Judge) Set(as []Account) {
j.mu.Lock()
defer j.mu.Unlock()
j.accounts = append([]Account(nil), as...)
declared := map[string]bool{}
for _, a := range as {
declared[a.ID] = true
}
for id := range j.kept {
if !declared[id] {
delete(j.kept, id)
}
}
}
// Last is the statement of the last look.
func (j *Judge) Last() Statement {
j.mu.Lock()
defer j.mu.Unlock()
return j.last
}
// Look reads every account once, and answers the statement and whether any verdict changed since the
// last look.
func (j *Judge) Look(ctx context.Context) (Statement, bool) {
j.mu.Lock()
defer j.mu.Unlock()
now := j.Now()
st := Statement{At: now}
changed := len(j.last.Accounts) != len(j.accounts)
for _, a := range j.accounts {
state, reason := j.judge(ctx, a)
k := j.kept[a.ID]
if k == nil || k.State != state || k.Reason != reason {
changed = true
k = &Verdict{Account: a, State: state, Reason: reason, Since: now}
j.kept[a.ID] = k
}
k.Account = a
if state == Unhealthy {
k.Streak++
} else {
k.Streak = 0
}
st.Accounts = append(st.Accounts, *k)
}
j.last = st
return st, changed
}
// judge is one account's verdict on one look.
func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
in, err := j.reader.InDatabase(ctx, a.Name)
if err != nil {
return Unknown, "the user database could not be read: " + firstLine(err.Error())
}
listed := map[string]bool{}
for _, g := range in {
listed[g] = true
}
var missing, inDB []string
for _, g := range a.Groups {
if listed[g] {
inDB = append(inDB, g)
} else {
missing = append(missing, g)
}
}
if len(missing) > 0 {
return Unhealthy, fmt.Sprintf("not in the group %s: the apply has not put %s there; its outcome says why",
strings.Join(missing, ", "), a.Name)
}
s, err := j.reader.Session(ctx, a.Name, inDB)
if err != nil {
return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error())
}
if !s.Running {
// Nobody is logged in, and the account does not linger: the next login takes every group.
return Healthy, ""
}
var lacking []string
for _, g := range inDB {
if !s.Has[g] {
lacking = append(lacking, g)
}
}
if len(lacking) > 0 {
return Unhealthy, fmt.Sprintf("%s: %s is in the group %s, and its running session began before it was; "+
"log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", "))
}
return Healthy, ""
}
func firstLine(s string) string {
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return line
}