systemd: port to Go, and read a system unit's journal as root

The journal verb ran journalctl as the operator account, which outside the
journal's group sees only its own entries: every system service read
'-- No entries --', and a person reached for a shell. The read now
escalates with sudo -n like the acts; ported to Go with every test. hq
issue 255.
This commit is contained in:
jochen
2026-10-05 18:09:42 +02:00
parent 6a42bafb1c
commit c42f1ce45b
11 changed files with 806 additions and 519 deletions
-242
View File
@@ -1,242 +0,0 @@
// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177).
//
// Who asks. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
// and launches this bundle as a process of its own (ADR 0188, ADR 0193) with the runtime's words:
// HOME, a PATH, MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME — and no session words.
//
// The system manager is the machine's. Reading it needs nothing; acting on it (start, stop,
// restart, enable, disable) is refused by polkit to an account that is not root, so those acts go
// through `sudo -n`, as the packet filter's and the intrusion prevention's do, and a refusal is
// named by how it failed.
//
// The user manager is the operator account's own, and this process IS that account. systemctl and
// journalctl find it by the account's runtime directory, /run/user/<uid>, which the runtime's
// environment does not name; so a user-scope call is given XDG_RUNTIME_DIR and the session bus
// there. It answers only while the account's manager runs — a login, or lingering enabled — and
// when it does not, that is said, never read as "no units".
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { userInfo } from "node:os";
export type Scope = "system" | "user";
export type Act = "start" | "stop" | "restart" | "enable" | "disable";
export interface Unit {
unit: string;
load: string;
active: string;
sub: string;
description: string;
}
/** What a command did: its output, its exit status, and the spawn error when it never ran. */
export interface Ran {
stdout: string;
stderr: string;
status: number;
/** Why it did not run to an answer: the spawn failure's code ("ENOENT" when the program is not
* there), or that it was ended for taking too long. */
error?: string;
}
/** A command runner, so the verbs can be tested without a service manager. */
export type Runner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<Ran>;
/** How long one systemctl or journalctl may take: below the runtime's thirty-second call limit, so
* a manager that hangs is answered as such rather than as a call the runtime gave up on. */
export const CALL_TIMEOUT_MS = 20_000;
export const execRunner: Runner = (cmd, args, env) =>
new Promise((resolve) => {
execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, env: env ?? process.env, timeout: CALL_TIMEOUT_MS }, (err, stdout, stderr) => {
const e = err as (Error & { code?: unknown; killed?: boolean }) | null;
if (e?.killed) {
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 124, error: `no answer within ${CALL_TIMEOUT_MS / 1000} s` });
return;
}
if (e && typeof e.code === "string") {
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 127, error: e.code });
return;
}
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: e ? (typeof e.code === "number" ? e.code : 1) : 0 });
});
});
/** The first line of a unit file the host writes for a module's own process (mesh-host
* internal/apply/process.go, unitFor). A unit loaded from a file that begins so is one the mesh
* declares, and the host writes it back at its next apply. */
export const MESH_UNIT_HEADER = "# Generated by the mesh.";
/** The acts that change the system manager's state, which polkit keeps from a non-root account. */
const ACTS: ReadonlySet<string> = new Set<Act>(["start", "stop", "restart", "enable", "disable"]);
/** The command as it is run: as given when this process is root or the call only reads, else an
* act on the system manager through sudo without a prompt. */
export function escalated(cmd: string, args: string[], scope: Scope, uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0 || scope === "user" || cmd !== "systemctl" || !ACTS.has(args[0] ?? "")) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** The words that let systemctl and journalctl reach the account's own manager. */
export function sessionEnv(uid: number, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const runtime = `/run/user/${uid}`;
return { ...base, XDG_RUNTIME_DIR: runtime, DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtime}/bus` };
}
export interface Options {
/** The operator account, as the mesh told the runtime. */
account: string;
/** This process's user id and name. */
uid: number;
user: string;
run?: Runner;
/** Reads a unit file, to tell whether the mesh wrote it. */
read?: (path: string) => Promise<string>;
}
export class ServiceManager {
private readonly o: Options;
private readonly run: Runner;
private readonly read: (path: string) => Promise<string>;
constructor(o: Options) {
this.o = o;
this.run = o.run ?? execRunner;
this.read = o.read ?? ((p) => readFile(p, "utf8"));
}
static fromEnv(env: NodeJS.ProcessEnv): ServiceManager {
const me = userInfo();
return new ServiceManager({ account: env.MESH_OPERATOR_ACCOUNT?.trim() || me.username, uid: me.uid, user: me.username });
}
/** One call to systemctl or journalctl in a scope, failing with what went wrong named. */
async call(scope: Scope, cmd: "systemctl" | "journalctl", ...args: string[]): Promise<string> {
let env: NodeJS.ProcessEnv | undefined;
if (scope === "user") {
// The user manager is the account's, and only the account's own process reaches it with
// plain --user. The runtime is that account; anything else is a runtime this was not
// written for, and is said rather than answered from the wrong manager.
if (this.o.user !== this.o.account) {
throw new Error(`the user scope is ${this.o.account}'s service manager, and this runs as ${this.o.user}`);
}
env = sessionEnv(this.o.uid);
args = ["--user", ...args];
}
const [program, argv] = escalated(cmd, args, scope, this.o.uid);
const r = await this.run(program, argv, env);
if (r.status === 0 && !r.error) {
// systemctl answers a user manager it cannot reach on stderr and still exits 0 for some
// verbs (list-units among them): that is a failure, not an empty answer.
if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(r.stderr)) throw this.unreachable(r.stderr);
return r.stdout;
}
throw this.failure(cmd, program, scope, r);
}
private unreachable(said: string): Error {
return new Error(
`${this.o.account}'s own service manager does not answer at /run/user/${this.o.uid} — the account has no ` +
`session and does not linger (loginctl enable-linger ${this.o.account}): ${firstLine(said)}`,
);
}
/** What failed, named by how it failed: sudo missing is a spawn error, sudo refusing speaks on its
* own stderr line, polkit refusing says so, an unreachable user manager says so, and the rest is
* the tool's own last line. */
private failure(cmd: string, program: string, scope: Scope, r: Ran): Error {
const said = `${r.stderr}\n${r.stdout}`.trim();
if (r.error === "ENOENT") {
return program === "sudo"
? new Error(`${cmd} needs root for this, and sudo is not installed here for the runtime's account to escalate with`)
: new Error(`${cmd} is not installed on this machine`);
}
if (r.error) return new Error(`${cmd} did not answer: ${r.error}`);
if (program === "sudo" && /^sudo:/m.test(said)) {
return new Error(`${cmd} needs root for this and the runtime's account may not run it without a prompt: ${firstLine(said)}`);
}
if (/interactive authentication/i.test(said)) {
return new Error(`the service manager refused the runtime's account: ${firstLine(said)}`);
}
if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(said)) return this.unreachable(said);
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
return new Error(lines.length ? `${cmd} failed (${r.status}): ${lines[0]}` : `${cmd} failed with status ${r.status}`);
}
async units(scope: Scope, pattern?: string): Promise<Unit[]> {
const args = ["list-units", "--all", "--no-legend", "--plain", "--no-pager"];
if (pattern) args.push("--", pattern);
const stdout = await this.call(scope, "systemctl", ...args);
return stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [unit, load, active, sub, ...rest] = l.split(/\s+/);
return { unit, load, active, sub, description: rest.join(" ") };
});
}
/** One unit's state, and whether the mesh declares it.
*
* **Declared** is read from the unit file systemd loaded (FragmentPath): the host writes every
* unit of a module's own process whole, under its own header, and writes it back at its next
* apply. That is the case a person's act is undone in, so it is the one the answer must name.
* A unit the mesh only puts into a state through the `service` shape — a package's own unit —
* carries no mark, and the host's record of it is root's; such a unit answers false here. */
async status(scope: Scope, unit: string): Promise<Record<string, string | boolean>> {
const props = ["LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"];
const stdout = await this.call(scope, "systemctl", "show", unitArg(unit), "--no-pager", ...props.map((p) => `--property=${p}`));
const out: Record<string, string | boolean> = { unit, scope };
for (const line of stdout.split("\n")) {
const i = line.indexOf("=");
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
}
out.mesh_declared = await this.writtenByMesh(String(out.FragmentPath ?? ""));
return out;
}
private async writtenByMesh(path: string): Promise<boolean> {
if (!path) return false;
const text = await this.read(path).catch(() => "");
return text.startsWith(MESH_UNIT_HEADER);
}
async act(scope: Scope, verb: Act, unit: string): Promise<Record<string, unknown>> {
await this.call(scope, "systemctl", verb, unitArg(unit));
const after = await this.status(scope, unit);
const answer: Record<string, unknown> = { unit, scope, verb, ok: true, active: after.ActiveState, boot: after.UnitFileState, mesh_declared: after.mesh_declared };
if (after.mesh_declared) answer.note = "the mesh declares this unit: the host restores its declared state at its next apply";
return answer;
}
async journal(scope: Scope, unit: string, lines: number): Promise<{ unit: string; scope: Scope; lines: string[] }> {
const stdout = await this.call(scope, "journalctl", "--no-pager", "-n", String(lines), "-u", unitArg(unit), "-o", "short-iso");
return { unit, scope, lines: stdout.split("\n").filter(Boolean) };
}
/** Every failed unit in both managers. A manager that does not answer is reported as such,
* beside the other's answer — never as "nothing failed". */
async failed(): Promise<{ system: Unit[] | { error: string }; user: Unit[] | { error: string } }> {
const failedIn = async (scope: Scope) => {
try {
return (await this.units(scope)).filter((u) => u.active === "failed");
} catch (err) {
return { error: (err as Error).message };
}
};
return { system: await failedIn("system"), user: await failedIn("user") };
}
}
/** A unit's name as an argument: never something systemctl or journalctl would read as an option,
* which under sudo would be root's option. */
export function unitArg(unit: string): string {
if (!unit || unit.startsWith("-") || /[\s\0]/.test(unit)) throw new Error(`${JSON.stringify(unit)} is not a unit's name`);
return unit;
}
function firstLine(text: string): string {
return text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
}
+349
View File
@@ -0,0 +1,349 @@
package main
// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177).
//
// Who asks. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 §4), and
// launches this bundle as a process of its own (ADR 0188, ADR 0193) with the runtime's words: HOME, a PATH,
// MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME — and no session words.
//
// The system manager is the machine's. Reading its units needs nothing; acting on it (start, stop, restart,
// enable, disable) is refused by polkit to an account that is not root, so those acts go through `sudo -n`,
// as the packet filter's and the intrusion prevention's do, and a refusal is named by how it failed.
//
// **So does reading a system unit's journal** (novox/hq issue 255). journalctl shows an account that is
// neither root nor in the journal's group only that account's own entries, and answers "-- No entries --" —
// which read as a quiet service, not as a refusal. Every system service's journal was empty through this
// verb, and a person reached for a shell to read it.
//
// The user manager is the operator account's own, and this process IS that account. systemctl and
// journalctl find it by the account's runtime directory, /run/user/<uid>, which the runtime's environment
// does not name; so a user-scope call is given XDG_RUNTIME_DIR and the session bus there. It answers only
// while the account's manager runs — a login, or lingering enabled — and when it does not, that is said,
// never read as "no units".
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// Scope is which service manager: the machine's, or the operator account's own.
type Scope string
const (
System Scope = "system"
User Scope = "user"
)
// Unit is one unit as list-units answers it.
type Unit struct {
Unit string `json:"unit"`
Load string `json:"load"`
Active string `json:"active"`
Sub string `json:"sub"`
Description string `json:"description"`
}
// Ran is what a command did: its output, its exit status, and why it did not run to an answer.
type Ran struct {
Stdout, Stderr string
Status int
// Error is "ENOENT" when the program is not there, or that it took too long.
Error string
}
// Runner runs a command, so the verbs can be tested without a service manager.
type Runner func(cmd string, args []string, env []string) Ran
// CallTimeout is how long one systemctl or journalctl may take: below the runtime's thirty-second call
// limit, so a manager that hangs is answered as such rather than as a call the runtime gave up on.
const CallTimeout = 20 * time.Second
func execRunner(cmd string, args []string, env []string) Ran {
ctx, cancel := context.WithTimeout(context.Background(), CallTimeout)
defer cancel()
c := exec.CommandContext(ctx, cmd, args...)
if env != nil {
c.Env = env
}
var out, errb bytes.Buffer
c.Stdout, c.Stderr = &out, &errb
err := c.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
switch {
case ctx.Err() == context.DeadlineExceeded:
r.Status, r.Error = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Error = 127, "ENOENT"
case err != nil:
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Status = exit.ExitCode()
} else {
r.Status, r.Error = 127, err.Error()
}
}
return r
}
// MeshUnitHeader is the first line of a unit file the host writes for a module's own process (mesh-host
// internal/apply/process.go, unitFor). A unit loaded from a file that begins so is one the mesh declares,
// and the host writes it back at its next apply.
const MeshUnitHeader = "# Generated by the mesh."
// acts are the verbs that change the system manager's state, which polkit keeps from a non-root account.
var acts = map[string]bool{"start": true, "stop": true, "restart": true, "enable": true, "disable": true}
// escalated is the command as it is run: as given when this process is root, or in the user scope, or
// when the call is a systemctl read; else through sudo without a prompt — an act on the system manager,
// or a read of the system journal (issue 255).
func escalated(cmd string, args []string, scope Scope, uid int) (string, []string) {
if uid == 0 || scope == User {
return cmd, args
}
if cmd == "journalctl" || (cmd == "systemctl" && len(args) > 0 && acts[args[0]]) {
return "sudo", append([]string{"-n", cmd}, args...)
}
return cmd, args
}
// sessionEnv is the words that let systemctl and journalctl reach the account's own manager.
func sessionEnv(uid int, base []string) []string {
runtime := fmt.Sprintf("/run/user/%d", uid)
out := []string{}
for _, kv := range base {
if !strings.HasPrefix(kv, "XDG_RUNTIME_DIR=") && !strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") {
out = append(out, kv)
}
}
return append(out, "XDG_RUNTIME_DIR="+runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path="+runtime+"/bus")
}
// Manager asks the service managers.
type Manager struct {
// Account is the operator account, as the mesh told the runtime.
Account string
// UID and User are this process's.
UID int
User string
Run Runner
// Read reads a unit file, to tell whether the mesh wrote it.
Read func(path string) (string, error)
// Env is this process's environment, the base of a user-scope call's.
Env []string
}
var userBus = regexp.MustCompile(`(?i)Failed to connect to (user scope )?bus`)
// call is one call to systemctl or journalctl in a scope, failing with what went wrong named.
func (m *Manager) call(scope Scope, cmd string, args ...string) (string, error) {
var env []string
if scope == User {
// The user manager is the account's, and only the account's own process reaches it with plain
// --user. The runtime is that account; anything else is a runtime this was not written for, and
// is said rather than answered from the wrong manager.
if m.User != m.Account {
return "", fmt.Errorf("the user scope is %s's service manager, and this runs as %s", m.Account, m.User)
}
env = sessionEnv(m.UID, m.Env)
args = append([]string{"--user"}, args...)
}
program, argv := escalated(cmd, args, scope, m.UID)
r := m.Run(program, argv, env)
if r.Status == 0 && r.Error == "" {
// systemctl answers a user manager it cannot reach on stderr and still exits 0 for some verbs
// (list-units among them): that is a failure, not an empty answer.
if scope == User && userBus.MatchString(r.Stderr) {
return "", m.unreachable(r.Stderr)
}
return r.Stdout, nil
}
return "", m.failure(cmd, program, scope, r)
}
func (m *Manager) unreachable(said string) error {
return fmt.Errorf("%s's own service manager does not answer at /run/user/%d — the account has no session "+
"and does not linger (loginctl enable-linger %s): %s", m.Account, m.UID, m.Account, firstLine(said))
}
var (
sudoSaid = regexp.MustCompile(`(?m)^sudo:`)
polkit = regexp.MustCompile(`(?i)interactive authentication`)
)
// failure names what failed by how it failed: sudo missing is a spawn error, sudo refusing speaks on its
// own stderr line, polkit refusing says so, an unreachable user manager says so, and the rest is the
// tool's own first line.
func (m *Manager) failure(cmd, program string, scope Scope, r Ran) error {
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
if r.Error == "ENOENT" {
if program == "sudo" {
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
}
return fmt.Errorf("%s is not installed on this machine", cmd)
}
if r.Error != "" {
return fmt.Errorf("%s did not answer: %s", cmd, r.Error)
}
if program == "sudo" && sudoSaid.MatchString(said) {
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
}
if polkit.MatchString(said) {
return fmt.Errorf("the service manager refused the runtime's account: %s", firstLine(said))
}
if scope == User && userBus.MatchString(said) {
return m.unreachable(said)
}
if line := firstLine(said); line != "" {
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
}
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
}
var spaces = regexp.MustCompile(`\s+`)
// Units is the units a manager knows in a scope, narrowed to a pattern when one is given.
func (m *Manager) Units(scope Scope, pattern string) ([]Unit, error) {
args := []string{"list-units", "--all", "--no-legend", "--plain", "--no-pager"}
if pattern != "" {
args = append(args, "--", pattern)
}
out, err := m.call(scope, "systemctl", args...)
if err != nil {
return nil, err
}
units := []Unit{}
for _, line := range strings.Split(out, "\n") {
f := spaces.Split(strings.TrimSpace(line), -1)
if len(f) < 4 || f[0] == "" {
continue
}
units = append(units, Unit{Unit: f[0], Load: f[1], Active: f[2], Sub: f[3], Description: strings.Join(f[4:], " ")})
}
return units, nil
}
// Status is one unit's state, and whether the mesh declares it.
//
// **Declared** is read from the unit file systemd loaded (FragmentPath): the host writes every unit of a
// module's own process whole, under its own header, and writes it back at its next apply. That is the
// case a person's act is undone in, so it is the one the answer must name. A unit the mesh only puts into
// a state through the `service` shape — a package's own unit — carries no mark; such a unit answers false.
func (m *Manager) Status(scope Scope, unit string) (map[string]any, error) {
if err := unitArg(unit); err != nil {
return nil, err
}
props := []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"}
args := []string{"show", unit, "--no-pager"}
for _, p := range props {
args = append(args, "--property="+p)
}
out, err := m.call(scope, "systemctl", args...)
if err != nil {
return nil, err
}
answer := map[string]any{"unit": unit, "scope": string(scope)}
for _, line := range strings.Split(out, "\n") {
if k, v, ok := strings.Cut(line, "="); ok && k != "" {
answer[k] = v
}
}
fragment, _ := answer["FragmentPath"].(string)
answer["mesh_declared"] = m.writtenByMesh(fragment)
return answer, nil
}
func (m *Manager) writtenByMesh(path string) bool {
if path == "" {
return false
}
text, err := m.Read(path)
return err == nil && strings.HasPrefix(text, MeshUnitHeader)
}
// Act starts, stops, restarts, enables or disables one unit, and answers with the state after.
func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
if err := unitArg(unit); err != nil {
return nil, err
}
if _, err := m.call(scope, "systemctl", verb, unit); err != nil {
return nil, err
}
after, err := m.Status(scope, unit)
if err != nil {
return nil, err
}
answer := map[string]any{"unit": unit, "scope": string(scope), "verb": verb, "ok": true,
"active": after["ActiveState"], "boot": after["UnitFileState"], "mesh_declared": after["mesh_declared"]}
if after["mesh_declared"] == true {
answer["note"] = "the mesh declares this unit: the host restores its declared state at its next apply"
}
return answer, nil
}
// Journal is the last lines of one unit's journal.
func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) {
if err := unitArg(unit); err != nil {
return nil, err
}
out, err := m.call(scope, "journalctl", "--no-pager", "-n", strconv.Itoa(lines), "-u", unit, "-o", "short-iso")
if err != nil {
return nil, err
}
kept := []string{}
for _, l := range strings.Split(out, "\n") {
if l != "" {
kept = append(kept, l)
}
}
return map[string]any{"unit": unit, "scope": string(scope), "lines": kept}, nil
}
// Failed is every failed unit in both managers. A manager that does not answer is reported as such,
// beside the other's answer — never as "nothing failed".
func (m *Manager) Failed() map[string]any {
in := func(scope Scope) any {
units, err := m.Units(scope, "")
if err != nil {
return map[string]string{"error": err.Error()}
}
failed := []Unit{}
for _, u := range units {
if u.Active == "failed" {
failed = append(failed, u)
}
}
return failed
}
return map[string]any{"system": in(System), "user": in(User)}
}
// unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be
// root's option.
func unitArg(unit string) error {
if unit == "" || strings.HasPrefix(unit, "-") || strings.ContainsAny(unit, " \t\n\r\x00") {
return fmt.Errorf("%q is not a unit's name", unit)
}
return nil
}
func firstLine(text string) string {
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
func readFile(path string) (string, error) {
raw, err := os.ReadFile(path)
return string(raw), err
}
@@ -0,0 +1,294 @@
package main
// The service manager's verbs over a fake runner (novox/hq ADR 0177, to-be 41 WP4), ported with the
// TypeScript module's tests: which manager a call reaches and how, acts on the system manager escalated,
// failures named rather than read as empty answers, whether the mesh declares a unit — and the system
// journal read escalated (novox/hq issue 255).
import (
"encoding/json"
"errors"
"os"
"reflect"
"strings"
"testing"
)
type call struct {
cmd string
args []string
env []string
}
func fake(answer func(c call) Ran, calls *[]call) Runner {
return func(cmd string, args []string, env []string) Ran {
c := call{cmd, args, env}
if calls != nil {
*calls = append(*calls, c)
}
return answer(c)
}
}
const (
list = "sshd.service loaded active running OpenSSH Daemon\nbroken.service loaded failed failed A broken thing\n"
showMesh = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=42\nExecMainStatus=0\nDescription=showcase, a mesh daemon\nFragmentPath=/etc/systemd/system/showcase.service\n"
showPackage = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=7\nExecMainStatus=0\nDescription=OpenSSH Daemon\nFragmentPath=/usr/lib/systemd/system/sshd.service\n"
)
var files = map[string]string{
"/etc/systemd/system/showcase.service": MeshUnitHeader + " Do not edit — this file is replaced whenever the\n[Unit]\n",
"/usr/lib/systemd/system/sshd.service": "[Unit]\nDescription=OpenSSH Daemon\n",
}
func read(p string) (string, error) {
if s, ok := files[p]; ok {
return s, nil
}
return "", errors.New("ENOENT")
}
func manager(run Runner, uid int, name string) *Manager {
return &Manager{Account: "operator", UID: uid, User: name, Run: run, Read: read, Env: []string{"HOME=/h"}}
}
func operator(run Runner) *Manager { return manager(run, 1000, "operator") }
func TestAnActAndASystemJournalReadGoThroughSudoUnlessRoot(t *testing.T) {
for _, verb := range []string{"start", "stop", "restart", "enable", "disable"} {
if p, _ := escalated("systemctl", []string{verb, "x.service"}, System, 1000); p != "sudo" {
t.Errorf("%s was not escalated", verb)
}
}
if p, a := escalated("systemctl", []string{"restart", "sshd.service"}, System, 1000); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "systemctl", "restart", "sshd.service"}) {
t.Errorf("%s %v", p, a)
}
if p, _ := escalated("systemctl", []string{"restart", "sshd.service"}, System, 0); p != "systemctl" {
t.Error("root escalated")
}
for _, args := range [][]string{{"show", "sshd.service"}, {"list-units", "restart"}} {
if p, _ := escalated("systemctl", args, System, 1000); p != "systemctl" {
t.Errorf("a read was escalated: %v", args)
}
}
if p, _ := escalated("systemctl", []string{"--user", "restart", "x.service"}, User, 1000); p != "systemctl" {
t.Error("the user scope was escalated")
}
// The system journal is read as root: unescalated, an account outside the journal's group sees only
// its own entries and every service's journal reads empty (issue 255).
if p, a := escalated("journalctl", []string{"-u", "x"}, System, 1000); p != "sudo" || a[1] != "journalctl" {
t.Errorf("the system journal read was not escalated: %s %v", p, a)
}
if p, _ := escalated("journalctl", []string{"--user", "-u", "x"}, User, 1000); p != "journalctl" {
t.Error("the account's own journal was escalated")
}
}
func TestTheUserScopeIsPlainUserWithTheAccountsRuntimeDirectoryAndBus(t *testing.T) {
var calls []call
m := manager(fake(func(call) Ran { return Ran{Stdout: list} }, &calls), 1234, "operator")
if _, err := m.Units(User, ""); err != nil {
t.Fatal(err)
}
if calls[0].cmd != "systemctl" || calls[0].args[0] != "--user" {
t.Fatalf("%+v", calls[0])
}
env := strings.Join(calls[0].env, "\n")
if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") {
t.Fatalf("%v", calls[0].env)
}
if _, err := m.Journal(User, "watcher.service", 10); err != nil {
t.Fatal(err)
}
if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" {
t.Fatalf("%+v", calls[1])
}
}
func TestTheSystemScopeIsGivenNoSessionWords(t *testing.T) {
var calls []call
if _, err := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls)).Units(System, ""); err != nil {
t.Fatal(err)
}
if calls[0].env != nil {
t.Fatalf("%v", calls[0].env)
}
for _, a := range calls[0].args {
if a == "--user" {
t.Fatal("--user in a system call")
}
}
}
func TestTheUserScopeFromAnotherAccountIsRefused(t *testing.T) {
m := manager(fake(func(call) Ran { return Ran{Stdout: list} }, nil), 0, "root")
if _, err := m.Units(User, ""); err == nil || !strings.Contains(err.Error(), "operator's service manager, and this runs as root") {
t.Fatalf("%v", err)
}
}
func TestASystemActEscalatesAndAnswersTheStateAfter(t *testing.T) {
var calls []call
m := operator(fake(func(c call) Ran {
if contains(c.args, "show") {
return Ran{Stdout: showPackage}
}
return Ran{}
}, &calls))
r, err := m.Act(System, "restart", "sshd.service")
if err != nil {
t.Fatal(err)
}
if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "restart", "sshd.service"}) {
t.Fatalf("%v", got)
}
if r["ok"] != true || r["active"] != "active" || r["mesh_declared"] != false || r["note"] != nil {
t.Fatalf("%v", r)
}
}
func TestTheRestoreNoteIsOnlyOnAUnitTheMeshDeclares(t *testing.T) {
m := operator(fake(func(c call) Ran {
if contains(c.args, "show") {
return Ran{Stdout: showMesh}
}
return Ran{}
}, nil))
r, _ := m.Act(System, "stop", "showcase.service")
if r["mesh_declared"] != true || !strings.Contains(r["note"].(string), "host restores its declared state") {
t.Fatalf("%v", r)
}
}
func TestStatusSaysWhetherTheMeshDeclaresTheUnit(t *testing.T) {
answer := func(s string) Runner { return fake(func(call) Ran { return Ran{Stdout: s} }, nil) }
mesh, _ := operator(answer(showMesh)).Status(System, "showcase.service")
pkg, _ := operator(answer(showPackage)).Status(System, "sshd.service")
none, _ := operator(answer("LoadState=not-found\nFragmentPath=\n")).Status(System, "nope.service")
if mesh["mesh_declared"] != true || mesh["MainPID"] != "42" || pkg["mesh_declared"] != false || none["mesh_declared"] != false {
t.Fatalf("%v %v %v", mesh, pkg, none)
}
if MeshUnitHeader != "# Generated by the mesh." {
t.Fatal("the header is not the one the host writes")
}
}
func TestRefusalsAreNamed(t *testing.T) {
refused := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil))
if _, err := refused.Act(System, "start", "x.service"); err == nil || !strings.Contains(err.Error(), "may not run it without a prompt: sudo: a password is required") {
t.Fatalf("%v", err)
}
missing := operator(fake(func(call) Ran { return Ran{Status: 127, Error: "ENOENT"} }, nil))
if _, err := missing.Act(System, "start", "x.service"); err == nil || !strings.Contains(err.Error(), "sudo is not installed here") {
t.Fatalf("%v", err)
}
polkitRefused := manager(fake(func(call) Ran {
return Ran{Status: 1, Stderr: "Failed to stop x.service: Access denied as the requested operation requires interactive authentication.\n"}
}, nil), 0, "root")
if _, err := polkitRefused.Act(System, "stop", "x.service"); err == nil || !strings.Contains(err.Error(), "the service manager refused the runtime's account") {
t.Fatalf("%v", err)
}
failing := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: "Failed to list units: Connection timed out\n"} }, nil))
if _, err := failing.Units(System, ""); err == nil || !strings.Contains(err.Error(), "systemctl failed (1): Failed to list units: Connection timed out") {
t.Fatalf("%v", err)
}
}
func TestAnUnreachableUserManagerIsSaidEvenWhenSystemctlExitsZero(t *testing.T) {
said := "Failed to connect to user scope bus via local transport: No such file or directory\n"
m := operator(fake(func(call) Ran { return Ran{Stderr: said} }, nil))
if _, err := m.Units(User, ""); err == nil || !strings.Contains(err.Error(), "operator's own service manager does not answer at /run/user/1000") {
t.Fatalf("%v", err)
}
nonzero := operator(fake(func(call) Ran { return Ran{Status: 1, Stderr: said} }, nil))
if _, err := nonzero.Status(User, "x.service"); err == nil || !strings.Contains(err.Error(), "does not answer") {
t.Fatalf("%v", err)
}
}
func TestFailedReportsEachManagerAndOneThatDoesNotAnswerByItsError(t *testing.T) {
m := operator(fake(func(c call) Ran {
if c.args[0] == "--user" {
return Ran{Status: 1, Stderr: "Failed to connect to user scope bus via local transport: No such file or directory\n"}
}
return Ran{Stdout: list}
}, nil))
r := m.Failed()
system, _ := json.Marshal(r["system"])
if string(system) != `[{"unit":"broken.service","load":"loaded","active":"failed","sub":"failed","description":"A broken thing"}]` {
t.Fatalf("%s", system)
}
if e, ok := r["user"].(map[string]string); !ok || !strings.Contains(e["error"], "does not answer") {
t.Fatalf("%v", r["user"])
}
}
func TestAUnitsNameIsNeverAnOption(t *testing.T) {
for _, bad := range []string{"--host=elsewhere", "a b", ""} {
if unitArg(bad) == nil {
t.Errorf("%q accepted", bad)
}
}
var calls []call
m := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls))
if _, err := m.Act(System, "stop", "-H"); err == nil || len(calls) != 0 {
t.Fatalf("an option ran as a unit: %v %d", err, len(calls))
}
if _, err := m.Units(System, "-x*"); err != nil {
t.Fatal(err)
}
if a := calls[0].args; a[len(a)-2] != "--" || a[len(a)-1] != "-x*" {
t.Fatalf("%v", a)
}
}
// The manifest owns the systemd package, claims the seat's eight verbs, and lists exactly the tools served.
func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Capabilities []string `json:"capabilities"`
Tools []string `json:"tools"`
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `json:"claims"`
Resources []struct {
Type string `json:"type"`
Package string `json:"package"`
} `json:"resources"`
}
_ = json.Unmarshal(raw, &m)
owns := false
for _, r := range m.Resources {
owns = owns || (r.Type == "package" && r.Package == "systemd")
}
if !owns || !contains(m.Capabilities, "package-manager") {
t.Fatal("the manifest does not own the systemd package")
}
served := map[string]bool{}
for _, tool := range tools(operator(nil)) {
served[tool.Name] = true
}
want := map[string]bool{}
for _, v := range m.Claims[0].Serves {
want[seat+"."+v] = true
}
for _, n := range m.Tools {
want[n] = true
}
if !reflect.DeepEqual(served, want) {
t.Fatalf("served %v, the manifest says %v", served, want)
}
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+150
View File
@@ -0,0 +1,150 @@
// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in both scopes,
// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). The node
// tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193);
// it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with
// sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers
// about them. stdout is the MCP channel; this says nothing else.
package main
import (
"fmt"
"os"
"os/user"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
const seat = "node-service-manager"
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
var (
scopeArg = str(`"system" (the default) or "user": the operator account's own manager`)
unitArgS = str("the unit's name, as the service manager knows it")
)
func scopeOf(a map[string]any) (Scope, error) {
s, _ := a["scope"].(string)
switch s {
case "", "system":
return System, nil
case "user":
return User, nil
}
return "", fmt.Errorf("scope %q: \"system\" or \"user\"", s)
}
func unitOf(a map[string]any) (string, error) {
u, _ := a["unit"].(string)
if u = strings.TrimSpace(u); u == "" {
return "", fmt.Errorf("a unit is required")
}
return u, nil
}
func tools(m *Manager) []stdio.Tool {
act := func(verb, description string) stdio.Tool {
return stdio.Tool{Name: seat + "." + verb, Description: description,
Input: map[string]any{"scope": scopeArg, "unit": unitArgS},
Run: func(a map[string]any) (any, error) {
scope, err := scopeOf(a)
if err != nil {
return nil, err
}
unit, err := unitOf(a)
if err != nil {
return nil, err
}
return m.Act(scope, verb, unit)
}}
}
return []stdio.Tool{
{Name: seat + ".units",
Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
Input: map[string]any{"scope": scopeArg, "pattern": str("a glob the unit's name must match (optional)")},
Run: func(a map[string]any) (any, error) {
scope, err := scopeOf(a)
if err != nil {
return nil, err
}
pattern, _ := a["pattern"].(string)
units, err := m.Units(scope, pattern)
if err != nil {
return nil, err
}
return map[string]any{"scope": string(scope), "units": units}, nil
}},
{Name: seat + ".status",
Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and mesh_declared — true when its unit file is one the mesh wrote (a unit the mesh only puts into a state is not recognised from here).",
Input: map[string]any{"scope": scopeArg, "unit": unitArgS},
Run: func(a map[string]any) (any, error) {
scope, err := scopeOf(a)
if err != nil {
return nil, err
}
unit, err := unitOf(a)
if err != nil {
return nil, err
}
return m.Status(scope, unit)
}},
act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at its next apply."),
act("stop", "Stop one unit; for a unit the mesh declares, the answer says the host will restore its declared state."),
act("restart", "Restart one unit."),
act("enable", "Make one unit start at boot (or at the account's login, in user scope)."),
act("disable", "Stop one unit starting at boot (or at login, in user scope)."),
{Name: seat + ".journal",
Description: "The last lines of one unit's journal (at most 2000) — a system service's included: the read is escalated, so it is the service's own lines and not only the operator account's.",
Input: map[string]any{"scope": scopeArg, "unit": unitArgS,
"lines": map[string]any{"type": "number", "description": "how many lines from the end (default 100, at most 2000)"}},
Run: func(a map[string]any) (any, error) {
scope, err := scopeOf(a)
if err != nil {
return nil, err
}
unit, err := unitOf(a)
if err != nil {
return nil, err
}
// Bounded so the answer stays well below what the runtime carries back in one reply.
n := 100
if v, ok := a["lines"].(float64); ok && v >= 1 {
n = min(int(v), 2000)
}
return m.Journal(scope, unit, n)
}},
{Name: "systemd_failed",
Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.",
Run: func(map[string]any) (any, error) { return m.Failed(), nil }},
}
}
func fromEnv() *Manager {
me, _ := user.Current()
name := ""
if me != nil {
name = me.Username
}
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
if account == "" {
account = name
}
uid := os.Getuid()
if me != nil {
if n, err := strconv.Atoi(me.Uid); err == nil {
uid = n
}
}
return &Manager{Account: account, UID: uid, User: name, Run: execRunner, Read: readFile, Env: os.Environ()}
}
func main() {
if err := stdio.Serve("", tools(fromEnv())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+5
View File
@@ -0,0 +1,5 @@
module systemd-tools
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+6 -3
View File
@@ -29,9 +29,12 @@
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
"language": "go",
"system": "arch",
"from": "cmd/systemd-tools",
"binary": "systemd-tools",
"loads": [
"systemd-tools"
]
}
]
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@novox/module-systemd",
"version": "0.1.0",
"description": "systemd \u2014 the machine's service manager as a module: holds node-service-manager and answers for the units in both scopes (novox/hq ADR 0177). The host applies units; this answers about them.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
}
}
-167
View File
@@ -1,167 +0,0 @@
// The service manager's verbs over a fake runner (novox/hq ADR 0177, to-be 41 WP4): which manager
// a call reaches and how, acts on the system manager escalated, failures named rather than read as
// empty answers, and whether the mesh declares a unit.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { MESH_UNIT_HEADER, ServiceManager, escalated, sessionEnv, unitArg, type Ran, type Runner } from "../client.ts";
interface Call {
cmd: string;
args: string[];
env?: NodeJS.ProcessEnv;
}
function fake(answer: (c: Call) => Partial<Ran>, calls: Call[] = []): Runner {
return async (cmd, args, env) => {
const c = { cmd, args, env };
calls.push(c);
return { stdout: "", stderr: "", status: 0, ...answer(c) };
};
}
const LIST = "sshd.service loaded active running OpenSSH Daemon\nbroken.service loaded failed failed A broken thing\n";
const SHOW_MESH = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=42\nExecMainStatus=0\nDescription=showcase, a mesh daemon\nFragmentPath=/etc/systemd/system/showcase.service\n";
const SHOW_PACKAGE = "LoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\nMainPID=7\nExecMainStatus=0\nDescription=OpenSSH Daemon\nFragmentPath=/usr/lib/systemd/system/sshd.service\n";
const files: Record<string, string> = {
"/etc/systemd/system/showcase.service": `${MESH_UNIT_HEADER} Do not edit — this file is replaced whenever the\n[Unit]\n`,
"/usr/lib/systemd/system/sshd.service": "[Unit]\nDescription=OpenSSH Daemon\n",
};
const read = async (p: string) => {
if (p in files) return files[p];
throw new Error("ENOENT");
};
function manager(run: Runner, o: { uid?: number; user?: string } = {}): ServiceManager {
return new ServiceManager({ account: "operator", uid: o.uid ?? 1000, user: o.user ?? "operator", run, read });
}
test("an act on the system manager goes through sudo without a prompt unless this is root; reads never do", () => {
assert.deepEqual(escalated("systemctl", ["restart", "sshd.service"], "system", 1000), ["sudo", ["-n", "systemctl", "restart", "sshd.service"]]);
for (const verb of ["start", "stop", "enable", "disable"]) {
assert.equal(escalated("systemctl", [verb, "x.service"], "system", 1000)[0], "sudo");
}
assert.deepEqual(escalated("systemctl", ["restart", "sshd.service"], "system", 0), ["systemctl", ["restart", "sshd.service"]]);
assert.deepEqual(escalated("systemctl", ["show", "sshd.service"], "system", 1000), ["systemctl", ["show", "sshd.service"]]);
assert.deepEqual(escalated("systemctl", ["list-units", "restart"], "system", 1000)[0], "systemctl");
assert.deepEqual(escalated("systemctl", ["--user", "restart", "x.service"], "user", 1000)[0], "systemctl");
assert.deepEqual(escalated("journalctl", ["-u", "x"], "system", 1000)[0], "journalctl");
});
test("the user scope is plain --user, with the account's runtime directory and bus named", async () => {
const calls: Call[] = [];
const m = manager(fake(() => ({ stdout: LIST }), calls), { uid: 1234 });
await m.units("user");
assert.equal(calls[0].cmd, "systemctl");
assert.equal(calls[0].args[0], "--user");
assert.ok(!calls[0].args.some((a) => a.startsWith("--machine")));
assert.equal(calls[0].env?.XDG_RUNTIME_DIR, "/run/user/1234");
assert.equal(calls[0].env?.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1234/bus");
await m.journal("user", "watcher.service", 10);
assert.deepEqual(calls[1].args.slice(0, 1), ["--user"]);
assert.equal(calls[1].cmd, "journalctl");
assert.equal(calls[1].env?.XDG_RUNTIME_DIR, "/run/user/1234");
assert.deepEqual(sessionEnv(5, { HOME: "/h" }), { HOME: "/h", XDG_RUNTIME_DIR: "/run/user/5", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/5/bus" });
});
test("the system scope is given no session words", async () => {
const calls: Call[] = [];
await manager(fake(() => ({ stdout: LIST }), calls)).units("system");
assert.equal(calls[0].env, undefined);
assert.ok(!calls[0].args.includes("--user"));
});
test("the user scope from a process that is not the account is refused, not answered from the wrong manager", async () => {
const m = manager(fake(() => ({ stdout: LIST })), { user: "root", uid: 0 });
await assert.rejects(() => m.units("user"), /operator's service manager, and this runs as root/);
});
test("a system act escalates, and answers with the state after", async () => {
const calls: Call[] = [];
const m = manager(fake((c) => (c.args.includes("show") ? { stdout: SHOW_PACKAGE } : {}), calls));
const r = await m.act("system", "restart", "sshd.service");
assert.deepEqual([calls[0].cmd, ...calls[0].args], ["sudo", "-n", "systemctl", "restart", "sshd.service"]);
assert.equal(r.ok, true);
assert.equal(r.active, "active");
assert.equal(r.mesh_declared, false);
assert.equal(r.note, undefined, "no restore note on a unit the mesh did not write");
});
test("the restore note is attached only to a unit the mesh declares", async () => {
const m = manager(fake((c) => (c.args.includes("show") ? { stdout: SHOW_MESH } : {})));
const r = await m.act("system", "stop", "showcase.service");
assert.equal(r.mesh_declared, true);
assert.match(String(r.note), /host restores its declared state/);
});
test("status says whether the mesh declares the unit, from the unit file systemd loaded", async () => {
const mesh = await manager(fake(() => ({ stdout: SHOW_MESH }))).status("system", "showcase.service");
assert.equal(mesh.mesh_declared, true);
assert.equal(mesh.MainPID, "42");
const pkg = await manager(fake(() => ({ stdout: SHOW_PACKAGE }))).status("system", "sshd.service");
assert.equal(pkg.mesh_declared, false);
const none = await manager(fake(() => ({ stdout: "LoadState=not-found\nFragmentPath=\n" }))).status("system", "nope.service");
assert.equal(none.mesh_declared, false);
});
test("the header recognised is the one the host writes", () => {
// mesh-host internal/apply/process.go, unitFor: the first line of every unit the host writes.
assert.equal(MESH_UNIT_HEADER, "# Generated by the mesh.");
});
test("sudo refusing is named as a refusal; sudo missing is named as missing", async () => {
const refused = manager(fake(() => ({ status: 1, stderr: "sudo: a password is required\n" })));
await assert.rejects(() => refused.act("system", "start", "x.service"), /needs root for this and the runtime's account may not run it without a prompt: sudo: a password is required/);
const missing = manager(fake(() => ({ status: 127, error: "ENOENT" })));
await assert.rejects(() => missing.act("system", "start", "x.service"), /sudo is not installed here/);
});
test("polkit refusing is named", async () => {
const m = manager(fake(() => ({ status: 1, stderr: "Failed to stop x.service: Access denied as the requested operation requires interactive authentication.\n" })), { uid: 0, user: "root" });
await assert.rejects(() => m.act("system", "stop", "x.service"), /the service manager refused the runtime's account/);
});
test("a failed systemctl is an error, not an empty list", async () => {
const m = manager(fake(() => ({ status: 1, stderr: "Failed to list units: Connection timed out\n" })));
await assert.rejects(() => m.units("system"), /systemctl failed \(1\): Failed to list units: Connection timed out/);
});
test("an unreachable user manager is said, even when systemctl exits 0", async () => {
const said = "Failed to connect to user scope bus via local transport: No such file or directory\n";
const m = manager(fake(() => ({ status: 0, stderr: said })), { uid: 1000 });
await assert.rejects(() => m.units("user"), /operator's own service manager does not answer at \/run\/user\/1000/);
const nonzero = manager(fake(() => ({ status: 1, stderr: said })));
await assert.rejects(() => nonzero.status("user", "x.service"), /does not answer/);
});
test("failed reports each manager's failed units, and a manager that does not answer by its error", async () => {
const m = manager(fake((c) =>
c.args[0] === "--user" ? { status: 1, stderr: "Failed to connect to user scope bus via local transport: No such file or directory\n" } : { stdout: LIST }));
const r = await m.failed();
assert.deepEqual(r.system, [{ unit: "broken.service", load: "loaded", active: "failed", sub: "failed", description: "A broken thing" }]);
assert.ok(!Array.isArray(r.user));
assert.match((r.user as { error: string }).error, /does not answer/);
});
test("a unit's name is never an option", async () => {
assert.throws(() => unitArg("--host=elsewhere"), /is not a unit's name/);
assert.throws(() => unitArg("a b"), /is not a unit's name/);
assert.equal(unitArg("sshd.service"), "sshd.service");
const calls: Call[] = [];
const m = manager(fake(() => ({ stdout: LIST }), calls));
await assert.rejects(() => m.act("system", "stop", "-H"), /is not a unit's name/);
assert.equal(calls.length, 0, "nothing ran");
await m.units("system", "-x*");
assert.deepEqual(calls[0].args.slice(-2), ["--", "-x*"]);
});
test("the manifest owns the systemd package — the service manager's own, never a component module's", () => {
const m = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
assert.ok((m.resources ?? []).some((r: { type: string; package?: string }) => r.type === "package" && r.package === "systemd"));
assert.ok(m.capabilities.includes("package-manager"));
// networkd is a component of systemd and configures it; it never claims the package.
const networkd = JSON.parse(readFileSync(new URL("../../systemd-networkd/module.json", import.meta.url), "utf8"));
assert.ok(!(networkd.resources ?? []).some((r: { type: string; package?: string }) => r.type === "package" && r.package === "systemd"));
assert.deepEqual(m.claims[0].serves, ["units", "status", "start", "stop", "restart", "enable", "disable", "journal"]);
});
-74
View File
@@ -1,74 +0,0 @@
// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in
// both scopes, read and acted on by name — and the module's own reading of what has failed
// (novox/hq ADR 0177). The node tools runtime launches this bundle as a process of its own and
// serves what it registers (ADR 0188, ADR 0193); it runs as the operator account, so acts on the
// system manager escalate with sudo -n and the user scope is the account's own manager (client.ts).
// The host applies units; this answers about them.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { ServiceManager, type Scope } from "../client.js";
const scope = { type: "string", description: "\"system\" (the default) or \"user\": the operator account's own manager" };
const unit = { type: "string", description: "the unit's name, as the service manager knows it" };
function scopeOf(args: Readonly<Record<string, unknown>>): Scope {
const s = String(args.scope ?? "system");
if (s !== "system" && s !== "user") throw new Error(`scope ${JSON.stringify(s)}: "system" or "user"`);
return s;
}
function unitOf(args: Readonly<Record<string, unknown>>): string {
const u = String(args.unit ?? "").trim();
if (!u) throw new Error("a unit is required");
return u;
}
export function getSeatVerbs(manager: ServiceManager): ToolDefinition[] {
const act = (verb: "start" | "stop" | "restart" | "enable" | "disable", description: string): ToolDefinition => ({
name: verb,
description,
input: { type: "object", properties: { scope, unit }, required: ["unit"] },
run: async (args) => manager.act(scopeOf(args), verb, unitOf(args)),
});
return [
{
name: "units",
description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
input: { type: "object", properties: { scope, pattern: { type: "string", description: "a glob the unit's name must match (optional)" } } },
run: async (args) => ({ scope: scopeOf(args), units: await manager.units(scopeOf(args), args.pattern ? String(args.pattern) : undefined) }),
},
{
name: "status",
description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and mesh_declared — true when its unit file is one the mesh wrote (a unit the mesh only puts into a state is not recognised from here).",
input: { type: "object", properties: { scope, unit }, required: ["unit"] },
run: async (args) => manager.status(scopeOf(args), unitOf(args)),
},
act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at its next apply."),
act("stop", "Stop one unit; for a unit the mesh declares, the answer says the host will restore its declared state."),
act("restart", "Restart one unit."),
act("enable", "Make one unit start at boot (or at the account's login, in user scope)."),
act("disable", "Stop one unit starting at boot (or at login, in user scope)."),
{
name: "journal",
description: "The last lines of one unit's journal (at most 2000).",
input: { type: "object", properties: { scope, unit, lines: { type: "number", description: "how many lines from the end (default 100, at most 2000)" } }, required: ["unit"] },
run: async (args) => {
// Bounded so the answer stays well below what the runtime carries back in one reply.
const n = Math.floor(Number(args.lines ?? 100));
return manager.journal(scopeOf(args), unitOf(args), Number.isFinite(n) && n > 0 ? Math.min(n, 2000) : 100);
},
},
];
}
export function getOwnTools(manager: ServiceManager): ToolDefinition[] {
return [
{
name: "systemd_failed",
description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.",
input: { type: "object", properties: {} },
run: async () => manager.failed(),
},
];
}
registerModuleTools("node-service-manager", (env) => getSeatVerbs(ServiceManager.fromEnv(env)));
registerModuleTools("systemd", (env) => getOwnTools(ServiceManager.fromEnv(env)));
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"tools/index.ts",
"client.ts"
]
}