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.
452 lines
19 KiB
Go
452 lines
19 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// arch is pacman and systemd.
|
|
type arch struct{}
|
|
|
|
func (arch) Name() string { return "arch" }
|
|
func (arch) Shapes() []declaration.Type { return everyShape() }
|
|
|
|
func (a arch) Confirm(ctx context.Context, run Runner) error {
|
|
if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil {
|
|
return fmt.Errorf(
|
|
"this is the arch host and pacman does not answer here. Either this machine is not "+
|
|
"Arch, or its package database is broken: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PackageInstalled asks the package database, having first established that it answers.
|
|
//
|
|
// The two-step is the trap this file exists to remember. `pacman -Q name` exits non-zero for a
|
|
// package that is not installed AND for a database that cannot be read, so believing the first
|
|
// answer reports a broken package manager as "nothing is installed" — absence read as fact.
|
|
// Proving the tool answers about something that certainly exists separates them.
|
|
func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) {
|
|
if err := a.Confirm(ctx, run); err != nil {
|
|
return false, fmt.Errorf("nothing can be said about %q: %w", name, err)
|
|
}
|
|
if _, err := run(ctx, "pacman", "-Q", name); err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// RemovePackage removes one package and nothing it depends on: `-R`, not `-Rs`, because what else
|
|
// relied on a dependency is not this declaration's to know. pacman keeps a configuration file the
|
|
// operator changed as `.pacsave`, which is what "never flushed" comes to once the front end is gone.
|
|
func (arch) RemovePackage(ctx context.Context, run Runner, name string) error {
|
|
_, err := run(ctx, "pacman", "-R", "--noconfirm", name)
|
|
return err
|
|
}
|
|
|
|
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
|
out, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// **The package manager's own words, and a name for the case that looks like a bug in the
|
|
// declaration and is not.** A stale index asks the mirrors for a version they have already
|
|
// superseded and gets a 404 from every one of them — so the package exists, the declaration is
|
|
// correct, and the machine's idea of what exists is old (novox/hq 04-ISSUES/002). A keyring as
|
|
// old as the index fails one step later, on the signature of whatever a mirror still had.
|
|
//
|
|
// **Read from everything pacman said.** Its errors go to stderr, which the runner folds into
|
|
// the error rather than the output; this classifier read the output alone and so never saw a
|
|
// single "failed retrieving file", and the control node reported a ten-week-old database as
|
|
// a mirror outage with a wall of 404s (novox/hq 04-ISSUES/205).
|
|
//
|
|
// **It is not fixed by syncing here.** `pacman -Sy <pkg>` installs a package built against
|
|
// libraries this machine does not have: a partial upgrade, which Arch does not support and
|
|
// which breaks the machine in a way that surfaces much later as something unrelated. The
|
|
// remedy is a full upgrade, and it is a decision about the whole machine rather than
|
|
// something to do silently in the middle of applying one resource. Whose decision, and on
|
|
// what schedule, is issue 205's question; until it is answered the host says what it sees.
|
|
said := strings.TrimSpace(out + "\n" + err.Error())
|
|
switch classifyInstallFailure(said) {
|
|
case installStale:
|
|
return fmt.Errorf(
|
|
"%s could not be fetched from any mirror, which is what a stale package index looks like: "+
|
|
"the package database on this machine is %s and the mirrors no longer serve what it "+
|
|
"names. It is fixed by upgrading the machine — a full upgrade (`pacman -Syu`) by its "+
|
|
"operator — before the mesh can install %s. The package and the declaration are probably both fine; the "+
|
|
"host does not sync one package by itself, because on this distribution that is a "+
|
|
"partial upgrade (novox/hq 04-ISSUES/205).\n\n%s",
|
|
name, syncDatabaseAge(), name, said)
|
|
case installMirrors:
|
|
return fmt.Errorf(
|
|
"no mirror could be reached to fetch %s, and the package database on this machine is "+
|
|
"%s: this reads as the mirrors or the network, not as this machine being out of "+
|
|
"date — try again when they answer.\n\n%s",
|
|
name, syncDatabaseAge(), said)
|
|
}
|
|
return fmt.Errorf("%w\n\n%s", err, strings.TrimSpace(out))
|
|
}
|
|
|
|
// How a failed install is read, from what the package manager said.
|
|
type installFailure int
|
|
|
|
const (
|
|
installOther installFailure = iota
|
|
// installStale: the machine's package database or keyring is older than what the mirrors
|
|
// serve — every mirror 404s the file the database names, or a package that did arrive fails
|
|
// its signature against a keyring that never saw the key.
|
|
installStale
|
|
// installMirrors: no mirror could be reached at all, and nothing says the database is old.
|
|
installMirrors
|
|
)
|
|
|
|
// classifyInstallFailure reads pacman's words, because there is nothing else to go on: the exit
|
|
// code is the same for every one of these.
|
|
func classifyInstallFailure(said string) installFailure {
|
|
lower := strings.ToLower(said)
|
|
gone := strings.Count(lower, "returned error: 404")
|
|
fetching := strings.Contains(lower, "failed retrieving file")
|
|
badSignature := strings.Contains(lower, "invalid or corrupted package (pgp signature)") ||
|
|
strings.Contains(lower, "signature from") && strings.Contains(lower, "is invalid") ||
|
|
strings.Contains(lower, "is unknown trust") ||
|
|
strings.Contains(lower, "could not be looked up remotely")
|
|
switch {
|
|
case badSignature:
|
|
return installStale
|
|
case fetching && gone > 0:
|
|
// Every mirror, not one: a single mirror failing is an ordinary transient thing and
|
|
// retrying is the answer. pacman walks its whole mirror list before giving up, so more
|
|
// than one 404 among the lines is the index being old rather than one host being wrong.
|
|
if gone > 1 || !strings.Contains(lower, "could not resolve host") &&
|
|
!strings.Contains(lower, "connection timed out") && !strings.Contains(lower, "failed to connect") {
|
|
return installStale
|
|
}
|
|
return installMirrors
|
|
case fetching:
|
|
return installMirrors
|
|
}
|
|
return installOther
|
|
}
|
|
|
|
// staleIndex is the yes-or-no form older callers and tests use.
|
|
func staleIndex(out string) bool { return classifyInstallFailure(out) == installStale }
|
|
|
|
// syncDatabaseAge says how old this machine's package database is, in words a person acts on:
|
|
// the newest of pacman's sync databases, dated, and how long ago that was. Said beside a failed
|
|
// install so a ten-week-old database is told apart from a mirror outage by reading one line.
|
|
//
|
|
// A variable so a test can say what the machine's database looks like without having one.
|
|
var syncDatabaseAge = func() string {
|
|
entries, err := filepath.Glob("/var/lib/pacman/sync/*.db")
|
|
if err != nil || len(entries) == 0 {
|
|
return "of unknown age (no sync database found under /var/lib/pacman/sync)"
|
|
}
|
|
var newest time.Time
|
|
for _, e := range entries {
|
|
info, err := os.Stat(e)
|
|
if err == nil && info.ModTime().After(newest) {
|
|
newest = info.ModTime()
|
|
}
|
|
}
|
|
if newest.IsZero() {
|
|
return "of unknown age"
|
|
}
|
|
return describeAge(newest, time.Now())
|
|
}
|
|
|
|
// describeAge is "from 2026-07-24, 10 weeks old" — the date for the record, the span for the eye.
|
|
func describeAge(when, now time.Time) string {
|
|
days := int(now.Sub(when).Hours() / 24)
|
|
span := fmt.Sprintf("%d days old", days)
|
|
switch {
|
|
case days < 1:
|
|
span = "less than a day old"
|
|
case days >= 14:
|
|
span = fmt.Sprintf("%d weeks old", days/7)
|
|
}
|
|
return fmt.Sprintf("from %s, %s", when.Format("2006-01-02"), span)
|
|
}
|
|
|
|
// ServiceState reads what systemd says about a unit.
|
|
//
|
|
// Two traps, and both were hit before this read what it now reads.
|
|
//
|
|
// The exit code is not the answer: `is-active` exits non-zero for every state except active.
|
|
//
|
|
// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that
|
|
// DOES NOT EXIST exactly as it does for one installed and stopped, so declaring a unit stopped
|
|
// reported success for a unit the host cannot manage at all. LoadState is what separates them,
|
|
// 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, asked := run(ctx, "systemctl", "show", unit,
|
|
"--property=LoadState", "--property=ActiveState", "--property=Type",
|
|
"--property=RemainAfterExit", "--property=ExecMainStatus")
|
|
|
|
var load, active, kind, remains, exited string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "LoadState":
|
|
load = value
|
|
case "ActiveState":
|
|
active = value
|
|
case "Type":
|
|
kind = value
|
|
case "RemainAfterExit":
|
|
remains = value
|
|
case "ExecMainStatus":
|
|
exited = value
|
|
}
|
|
}
|
|
|
|
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(
|
|
"%s does not exist on this machine. A declaration naming a unit that is not "+
|
|
"installed cannot be satisfied, and reporting it stopped would be reporting "+
|
|
"absence as success", unit)
|
|
case "masked":
|
|
return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit)
|
|
case "error", "bad-setting":
|
|
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load)
|
|
}
|
|
|
|
// **A one-shot that finished is not stopped.** A unit whose whole job is to apply something
|
|
// and exit — load a rule set, set a sysctl — is reported inactive the moment it succeeds, and
|
|
// unless it is told to linger there is no state in which it is ever "active". Reading that as
|
|
// "stopped" makes such a unit permanently unsatisfiable: the host starts it, it does its work,
|
|
// it exits, the host reads back "stopped" and reports failure — for ever, on every apply,
|
|
// while the thing it configured is in place and working.
|
|
//
|
|
// That is not hypothetical. It is what the firewall did on every machine it was ever assigned
|
|
// to: rules loaded, service reported failed, the mesh reported a machine not doing what it was
|
|
// told, and the only visible symptom was a red line about a unit nobody could see anything
|
|
// wrong with.
|
|
//
|
|
// So for that shape, what "running" means is "it ran, and it worked".
|
|
if kind == "oneshot" && remains != "yes" && active == "inactive" {
|
|
if exited == "0" || exited == "" {
|
|
return "running", nil
|
|
}
|
|
return "stopped", nil
|
|
}
|
|
|
|
switch active {
|
|
case "active", "activating", "reloading":
|
|
return "running", nil
|
|
case "inactive", "failed", "deactivating":
|
|
return "stopped", nil
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q, which is neither running nor stopped", unit, active)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
|
|
verb := "start"
|
|
if state == "stopped" {
|
|
verb = "stop"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|
|
|
|
// ServiceBoot reads whether a unit starts at boot.
|
|
//
|
|
// `is-enabled` has more than two answers, and `static` is the one that matters: the unit has no
|
|
// install section and CANNOT be enabled. Reading it as "disabled" would have the host try, fail,
|
|
// and blame the wrong thing — the same shape as reading a missing unit as "stopped".
|
|
func (arch) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "is-enabled", unit)
|
|
switch state := strings.TrimSpace(out); state {
|
|
case "enabled", "enabled-runtime", "alias":
|
|
return "enabled", nil
|
|
case "disabled":
|
|
return "disabled", nil
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about whether %s starts at boot", unit)
|
|
case "static":
|
|
return "", fmt.Errorf(
|
|
"%s is static — it has no install section, so it cannot be enabled or disabled. "+
|
|
"Something else pulls it in, and that is what a declaration should name", unit)
|
|
case "masked", "masked-runtime":
|
|
return "", fmt.Errorf("%s is masked, so its boot state cannot be declared", unit)
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q at boot, which is neither enabled nor disabled",
|
|
unit, state)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error {
|
|
verb := "enable"
|
|
if boot == "disabled" {
|
|
verb = "disable"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|
|
|
|
// CreateUser makes a login with useradd.
|
|
//
|
|
// `--create-home` because a user whose home does not exist is a user nothing can be delivered
|
|
// to, and delivering a shell's configuration is most of why the mesh knows about users at all.
|
|
func (arch) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
|
|
args := []string{"--create-home"}
|
|
if home != "" {
|
|
args = append(args, "--home-dir", home)
|
|
}
|
|
if shell != "" {
|
|
args = append(args, "--shell", shell)
|
|
}
|
|
if _, err := run(ctx, "useradd", append(args, name)...); err != nil {
|
|
return fmt.Errorf("cannot create the user %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (arch) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
|
|
if _, err := run(ctx, "usermod", "--shell", shell, name); err != nil {
|
|
return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddUserToGroup appends, and `--append` is the whole point: without it usermod REPLACES the
|
|
// user's supplementary groups, so a declaration naming one group would silently remove every
|
|
// other — including the ones that make a login able to use a machine at all.
|
|
func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
|
|
if _, err := run(ctx, "usermod", "--append", "--groups", group, name); err != nil {
|
|
return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ServiceUnitFile says where the service manager loads a unit from — systemd's FragmentPath. It
|
|
// is how the host tells a unit an administrator installed, under /etc or /run, from one a package
|
|
// ships under /usr (novox/hq ADR 0103). Empty, with no error, for a unit that loads from nowhere.
|
|
func (arch) ServiceUnitFile(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, err := run(ctx, "systemctl", "show", unit, "--property=FragmentPath")
|
|
if err != nil {
|
|
return "", fmt.Errorf("the service manager did not say where %s comes from: %w", unit, err)
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if path, ok := strings.CutPrefix(strings.TrimSpace(line), "FragmentPath="); ok {
|
|
return strings.TrimSpace(path), nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// ReloadUnits has systemd read its unit files again. A unit file that changed on disk is otherwise
|
|
// ignored: a restart runs the unit systemd already loaded, and the new text only takes effect
|
|
// after a reload nobody asked for.
|
|
func (arch) ReloadUnits(ctx context.Context, run Runner) error {
|
|
_, err := run(ctx, "systemctl", "daemon-reload")
|
|
return err
|
|
}
|
|
|
|
// ReloadService tells a running unit to read its configuration again, without stopping it.
|
|
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
|
|
}
|