systemd: act as the runtime's account can, and never read a failure as an answer
The node tools runtime runs as the operator account, not root, and gives its bundles no session words (novox/hq ADR 0175, 0188, 0193). So, per hq to-be 41 WP4: - system-scope start/stop/restart/enable/disable go through sudo -n when not root, as the packet filter and intrusion prevention do, and a refusal is named by how it failed; - user scope is plain --user with XDG_RUNTIME_DIR and the session bus of /run/user/<uid>; the dead --machine branches are gone; - a failed systemctl or journalctl is an error, and an unreachable user manager is said even when systemctl exits 0; systemd_failed reports it beside the other manager's answer instead of claiming nothing failed; - status says whether the mesh declares the unit: its loaded unit file begins with the header the host writes for a module's process. Only such a unit carries the restore note; - the package resource goes: the service manager is always present, and it collided with systemd-networkd's identical declaration; - calls are bounded below the runtime's call limit, a unit name is never an option, and the runner is injected so the tests use a fake one.
This commit is contained in:
+184
-35
@@ -1,14 +1,26 @@
|
||||
// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177).
|
||||
//
|
||||
// The system manager is the machine's. The user manager is the operator account's own: reached as
|
||||
// `systemctl --user --machine=<account>@` when this process is not that account (the node tools
|
||||
// runtime runs as the node's account, root when the host started it), and as plain `--user` when
|
||||
// it is. It answers only while the account's manager runs — a login, or lingering enabled.
|
||||
// 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;
|
||||
@@ -18,36 +30,144 @@ export interface Unit {
|
||||
description: string;
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; status: number }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||
const status = err && typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code) : err ? 1 : 0;
|
||||
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") + (err && !(err as { code?: unknown }).code ? err.message : ""), status });
|
||||
/** 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 {
|
||||
constructor(private readonly account: string) {}
|
||||
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 {
|
||||
return new ServiceManager(env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().username);
|
||||
const me = userInfo();
|
||||
return new ServiceManager({ account: env.MESH_OPERATOR_ACCOUNT?.trim() || me.username, uid: me.uid, user: me.username });
|
||||
}
|
||||
|
||||
/** The leading arguments that pick a manager. */
|
||||
scopeArgs(scope: Scope): string[] {
|
||||
if (scope !== "user") return [];
|
||||
return userInfo().username === this.account ? ["--user"] : ["--user", `--machine=${this.account}@`];
|
||||
/** 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);
|
||||
}
|
||||
|
||||
async systemctl(scope: Scope, ...args: string[]): Promise<{ stdout: string; stderr: string; status: number }> {
|
||||
return run("systemctl", [...this.scopeArgs(scope), ...args]);
|
||||
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.systemctl(scope, ...args);
|
||||
if (pattern) args.push("--", pattern);
|
||||
const stdout = await this.call(scope, "systemctl", ...args);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -58,36 +178,65 @@ export class ServiceManager {
|
||||
});
|
||||
}
|
||||
|
||||
async status(scope: Scope, unit: string): Promise<Record<string, string>> {
|
||||
/** 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.systemctl(scope, "show", unit, ...props.map((p) => `--property=${p}`));
|
||||
const out: Record<string, string> = { unit, scope };
|
||||
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;
|
||||
}
|
||||
|
||||
async act(scope: Scope, verb: "start" | "stop" | "restart" | "enable" | "disable", unit: string): Promise<Record<string, unknown>> {
|
||||
const { stderr, status } = await this.systemctl(scope, verb, unit);
|
||||
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);
|
||||
return { unit, scope, verb, ok: status === 0, stderr: stderr.trim(), active: after.ActiveState, boot: after.UnitFileState,
|
||||
note: "a unit the mesh declares is restored to its declared state at the host's next apply" };
|
||||
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 args = ["--no-pager", "-n", String(lines), "-u", unit, "-o", "short-iso"];
|
||||
if (scope === "user") {
|
||||
args.unshift(userInfo().username === this.account ? "--user" : `--machine=${this.account}@`, ...(userInfo().username === this.account ? [] : ["--user"]));
|
||||
}
|
||||
const { stdout } = await run("journalctl", args);
|
||||
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) };
|
||||
}
|
||||
|
||||
async failed(): Promise<{ system: Unit[]; user: Unit[] }> {
|
||||
const system = (await this.units("system")).filter((u) => u.active === "failed");
|
||||
const user = (await this.units("user").catch(() => [] as Unit[])).filter((u) => u.active === "failed");
|
||||
return { system, user };
|
||||
/** 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) ?? "";
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"module": "systemd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"service-manager",
|
||||
"package-manager"
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
@@ -24,13 +23,6 @@
|
||||
"tools": [
|
||||
"systemd_failed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "systemd"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@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'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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 declares no package — the service manager is always there, and networkd declares it too", () => {
|
||||
const m = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||
assert.ok(!(m.resources ?? []).some((r: { type: string }) => r.type === "package"));
|
||||
assert.ok(!m.capabilities.includes("package-manager"));
|
||||
assert.deepEqual(m.claims[0].serves, ["units", "status", "start", "stop", "restart", "enable", "disable", "journal"]);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
// 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). Served by the node tools runtime (ADR 0175); the host applies units, this
|
||||
// answers about them.
|
||||
// (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";
|
||||
|
||||
@@ -35,22 +37,23 @@ export function getSeatVerbs(manager: ServiceManager): ToolDefinition[] {
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
||||
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 the next apply."),
|
||||
act("stop", "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state."),
|
||||
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.",
|
||||
input: { type: "object", properties: { scope, unit, lines: { type: "number", description: "how many lines from the end (default 100)" } }, required: ["unit"] },
|
||||
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) => {
|
||||
const n = Number(args.lines ?? 100);
|
||||
return manager.journal(scopeOf(args), unitOf(args), Number.isFinite(n) && n > 0 ? Math.min(n, 5000) : 100);
|
||||
// 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);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -60,7 +63,7 @@ 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.",
|
||||
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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user