systemd: the service manager as a module — holds node-service-manager and answers for the units in both scopes
The holder of the seat the controller seeds under novox/hq ADR 0177. Eight verbs under the seat's name — units, status, start, stop, restart, enable, disable, journal — each taking an optional scope, "system" by default or "user" for the operator account's own manager, reached as `systemctl --user --machine=<account>@` when the runtime is not that account. One tool of its own, systemd_failed, for every failed unit in both scopes. A package, a claim and a bundle; no container, no process: served by the node tools runtime (ADR 0175) once it exists. `module check` passes against a controller that carries the seat; the tools type-check against the SDK.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// 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.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { userInfo } from "node:os";
|
||||
|
||||
export type Scope = "system" | "user";
|
||||
|
||||
export interface Unit {
|
||||
unit: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class ServiceManager {
|
||||
constructor(private readonly account: string) {}
|
||||
|
||||
static fromEnv(env: NodeJS.ProcessEnv): ServiceManager {
|
||||
return new ServiceManager(env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().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}@`];
|
||||
}
|
||||
|
||||
async systemctl(scope: Scope, ...args: string[]): Promise<{ stdout: string; stderr: string; status: number }> {
|
||||
return run("systemctl", [...this.scopeArgs(scope), ...args]);
|
||||
}
|
||||
|
||||
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);
|
||||
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(" ") };
|
||||
});
|
||||
}
|
||||
|
||||
async status(scope: Scope, unit: string): Promise<Record<string, string>> {
|
||||
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 };
|
||||
for (const line of stdout.split("\n")) {
|
||||
const i = line.indexOf("=");
|
||||
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
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);
|
||||
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" };
|
||||
}
|
||||
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"module": "systemd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"service-manager",
|
||||
"package-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-service-manager",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"units",
|
||||
"status",
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"enable",
|
||||
"disable",
|
||||
"journal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"systemd_failed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "systemd"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"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.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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.
|
||||
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 whether the mesh declares it.",
|
||||
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("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"] },
|
||||
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);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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.",
|
||||
input: { type: "object", properties: {} },
|
||||
run: async () => manager.failed(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("node-service-manager", (env) => getSeatVerbs(ServiceManager.fromEnv(env)));
|
||||
registerModuleTools("systemd", (env) => getOwnTools(ServiceManager.fromEnv(env)));
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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