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:
@@ -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) ?? "";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module systemd-tools
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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)));
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user