Compare commits

..
Author SHA1 Message Date
jochen 9ab58e79fb 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.
2026-10-02 16:47:48 +02:00
jochen a263bc36ba zsh: the shell as a module — package, the mesh's ~/.zshrc block, the login-shell seat and execute
The first module of the operator's environment (novox/hq to-be 37 §1, ADR 0173,
0176). A package, the mesh's default configuration as a block inside the
account's ~/.zshrc so the operator's own lines around it survive every push
(ADR 0174 as the host's `into: block` realises it), a `user` shape that makes
zsh the account's login shell, the `login-shell` seat declared with its one
verb, and a tools bundle: `execute` under the seat's name, `zsh_config` under
the module's. No container, no process: the tools are served by the node tools
runtime (ADR 0175), which does not exist yet — the bundle builds and the
manifest registers ahead of it. `module check` passes; the tools type-check
against the SDK.

Two things the manifest cannot yet say, left for the controller: the `user`
shape applies wherever the module is assigned, not only where it holds the
seat; and the runtime learns the account from MESH_OPERATOR_ACCOUNT, which
nothing sets yet.
2026-10-02 16:40:34 +02:00
mesh-admin d5c5415756 Merge pull request 'lab: the image carries python3, file, iproute2 and sudo' (#222) from jschoubben/lab-image-tools into main 2026-10-02 13:24:00 +00:00
jschoubben 6dfd2401c9 lab: the image carries what the builds and the lab call: python3, file, iproute2, sudo 2026-10-02 15:23:52 +02:00
mesh-admin 31923f70e7 Merge pull request 'lab: a run resolves the @novox scope from the forge's package registry' (#221) from jschoubben/lab-npm-scope into main 2026-10-02 13:13:29 +00:00
jschoubben 8cd4f199f1 lab: a run resolves the @novox scope from the forge's package registry 2026-10-02 15:13:22 +02:00
mesh-admin 1b9b298827 Merge pull request 'lab: compile from the module's root, so the runtime finds its tools' (#220) from jschoubben/lab-tools-path into main 2026-10-02 13:08:14 +00:00
jschoubben bce7b3a551 lab: compile from the module's root, so the runtime finds its tools
Both sources sit in tools/, so tsc took tools/ as the root and wrote
dist/index.js, while the runtime loads dist/tools/index.js: the module
started and served nothing.
2026-10-02 15:08:01 +02:00
mesh-admin 17d3d3e63a Merge pull request 'lab: declares the virtualisation capability (hq ADR 0172)' (#218) from jschoubben/the-lab-is-a-module-2 into main 2026-10-02 12:53:43 +00:00
mesh-admin 5c961c446f Merge pull request 'Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered' (#219) from fix/adr-0170-cited into main 2026-10-02 12:53:10 +00:00
jschoubben b77582f6a6 Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered after a collision on hq main 2026-10-02 14:52:26 +02:00
jschoubben b3865d240f lab: declares the virtualisation capability, which grants its daemon's socket 2026-10-02 14:48:10 +02:00
mesh-admin a81b94d4ab Merge pull request 'lab: the lab as a module, running beds when the mesh asks (hq ADR 0172)' (#217) from jschoubben/the-lab-is-a-module into main 2026-10-02 12:17:14 +00:00
jschoubben 67d1a400e8 lab: the lab as a module, running beds when the mesh asks
Five tools on the machine the lab runs on: check, run beds against
branches on the forge, a run's status, its log, and stop. A run checks
out every repository the lab builds, side by side, and runs the suite;
one at a time, answered at once with an id (novox/hq ADR 0172).
2026-10-02 14:15:42 +02:00
mesh-admin 1c201d59c9 Merge pull request 'nftables holds the node-packet-filter seat: rules, reload and remove, from a runtime with NET_ADMIN (hq ADR 0169)' (#216) from feat/the-firewall-seat-serves-its-verbs into main 2026-10-02 11:33:47 +00:00
19 changed files with 845 additions and 4 deletions
+31
View File
@@ -0,0 +1,31 @@
# lab's runtime: the tool runtime, carrying this module's code, and the toolchain the lab's suite
# builds the mesh with (novox/hq ADR 0172). It reaches the machine's virtualisation and container
# runtime through their sockets, so what it raises is what a hand run on this machine raises.
#
# Every download is pinned by its checksum: an image that builds the mesh is the last place to take
# whatever an upstream serves today.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/lab
COPY . .
RUN node /app/node_modules/typescript/bin/tsc tools/index.ts tools/runs.ts --rootDir . \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
RUN apt-get update \
&& apt-get install -y --no-install-recommends git make ca-certificates curl python3 file iproute2 sudo \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/go.tgz https://go.dev/dl/go1.26.8.linux-amd64.tar.gz \
&& echo "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b /tmp/go.tgz" | sha256sum -c - \
&& tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz
RUN curl -fsSL -o /usr/local/bin/incus https://github.com/lxc/incus/releases/download/v7.5.1/bin.linux.incus.x86_64 \
&& echo "7bd6223b369f4d693fcde695bd8549a73b5b3d403735329212483702aa22c179 /usr/local/bin/incus" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/incus
RUN curl -fsSL -o /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-28.5.2.tgz \
&& echo "ea90cfd12e1eeb12aa1c971741adb8bd4ed88e2a574eaac13f5029a1dbc6300d /tmp/docker.tgz" | sha256sum -c - \
&& tar -C /tmp -xzf /tmp/docker.tgz docker/docker && mv /tmp/docker/docker /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz
ENV PATH=/usr/local/go/bin:$PATH
COPY --from=build /app/modules/lab/dist /app/modules/lab/dist
ENV MESH_TOOL_MODULES=/app/modules/lab/dist/tools/index.js
+82
View File
@@ -0,0 +1,82 @@
{
"module": "lab",
"version": "1",
"capabilities": [
"container-runtime",
"virtualisation"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "work",
"type": "directory",
"path": "/var/lib/mesh-lab-runs",
"mode": "0700"
},
{
"id": "runtime-env",
"type": "file",
"path": "${dir:state}/lab.env",
"mode": "0600",
"content": "MESH_LAB_FORGE=${setting:forge}\n"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-lab",
"network": "host",
"env-file": [
"${dir:state}/lab.env"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:work}:${dir:work}",
"/var/run/docker.sock:/var/run/docker.sock",
"/var/lib/incus/unix.socket:/var/lib/incus/unix.socket"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LAB_WORK": "${dir:work}"
},
"restart-on": [
"runtime-env"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@novox/module-lab",
"version": "0.1.0",
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
+86
View File
@@ -0,0 +1,86 @@
// lab's tools — the lab, as the mesh asks for it (novox/hq ADR 0172). They run on the machine the
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
import { spawnSync } from "node:child_process";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
const forge = (env.MESH_LAB_FORGE ?? "").replace(/\/+$/, "");
return [
{
name: "lab_check",
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
input: {},
run: async () => {
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]);
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
},
},
{
name: "lab_run",
description:
"Run the lab's beds against branches on the forge: fresh checkouts of every repository the lab builds, " +
"side by side, then the suite on the named test files. Answers at once with the run's id; lab_status " +
"and lab_log follow it. One run at a time.",
input: {
tests: { type: "string", description: "the bed test files, comma-separated, relative to mesh-lab (e.g. test/integration/mesh.test.ts)" },
refs: {
type: "string",
description: `a JSON object of repository to branch, for any of ${REPOSITORIES.join(", ")}; the rest run main`,
},
},
run: async (args) => {
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
let refs: Record<string, string> = {};
if (args.refs) {
try {
refs = JSON.parse(String(args.refs)) as Record<string, string>;
} catch {
return { started: false, reason: "refs is not a JSON object of repository to branch" };
}
}
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
return { started: true, run: start(work, forge, tests, refs) };
},
},
{
name: "lab_status",
description: "A run's state, the commits it tested and how it ended — or every run, newest first, when no id is given.",
input: { id: { type: "string", description: "the run's id (optional)" } },
run: async (args) => {
if (args.id) return readStatus(work, String(args.id)) ?? { found: false, id: String(args.id) };
return { runs: listRuns(work).slice(0, 10) };
},
},
{
name: "lab_log",
description: "The last lines of a run's log.",
input: {
id: { type: "string", description: "the run's id" },
lines: { type: "number", description: "how many lines from the end (default 200)" },
},
run: async (args) => ({ id: String(args.id), log: tail(work, String(args.id), Number(args.lines ?? 200)) }),
},
{
name: "lab_stop",
description: "Stop a run and everything it started.",
input: { id: { type: "string", description: "the run's id" } },
run: async (args) => stop(work, String(args.id)) ?? { found: false, id: String(args.id) },
},
];
}
registerModuleTools("lab", (env) => getLabTools(env));
+175
View File
@@ -0,0 +1,175 @@
// A lab run: fresh checkouts of the named branches, side by side, then the lab's suite on the named
// beds (novox/hq ADR 0172).
//
// **A run is a detached script with its own process group**, so it outlives the tool call that started
// it and `stop` ends everything it started. It writes what it is doing to a status file beside its log,
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
export interface RunStatus {
id: string;
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
started: string;
ended?: string;
tests: string[];
refs: Record<string, string>;
commits?: Record<string, string>;
exit?: number;
pid?: number;
}
export function runDir(work: string, id: string): string {
return join(work, id);
}
function statusPath(work: string, id: string): string {
return join(runDir(work, id), "status.json");
}
export function readStatus(work: string, id: string): RunStatus | undefined {
try {
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
// A run whose process is gone while its status still says it is going was lost — the runtime or
// the machine restarted under it. Said, rather than left reading as running for ever.
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
s.state = "lost";
}
return s;
} catch {
return undefined;
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function listRuns(work: string): RunStatus[] {
if (!existsSync(work)) return [];
return readdirSync(work)
.filter((d) => d.startsWith("run-"))
.map((id) => readStatus(work, id))
.filter((s): s is RunStatus => !!s)
.sort((a, b) => b.started.localeCompare(a.started));
}
/** The run still going, if any: one at a time, because two would contend for the same machine. */
export function running(work: string): RunStatus | undefined {
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
}
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/**
* The script one run executes. Every step writes its state first, so a run that dies says where.
*
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
`const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=${JSON.stringify(state)};require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)}`;
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
].join("\n");
}).join("\n");
const bin = join(dir, "bin");
return `set -euo pipefail
cd ${shellQuote(dir)}
${setState("checking-out")}
${clones}
node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
${setState("building")}
# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
done
export MESH_LAB_HOST_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-host"))}
export MESH_LAB_BUNDLE=${shellQuote(join(dir, "mesh-host", "examples", "foundation-first-node-nats.lock"))}
export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "modules"))}
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}
${setState("running")}
cd ${shellQuote(join(dir, "mesh-lab"))}
node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join(" ")}
`;
}
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
const wrapper = `bash ${shellQuote(join(dir, "run.sh"))} > ${shellQuote(join(dir, "run.log"))} 2>&1; code=$?
node -e ${shellQuote(
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)} "$code"
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
child.unref();
status.pid = child.pid;
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
return status;
}
/** stop ends a run and everything it started, by its process group. */
export function stop(work: string, id: string): RunStatus | undefined {
const s = readStatus(work, id);
if (!s || !s.pid) return s;
if (["passed", "failed", "stopped", "lost"].includes(s.state)) return s;
s.state = "stopped";
writeFileSync(statusPath(work, id), JSON.stringify(s, null, 2));
try {
process.kill(-s.pid, "SIGTERM");
} catch {
// Already gone between the read and the kill.
}
return s;
}
/** tail is the last lines of a run's log. */
export function tail(work: string, id: string, lines: number): string {
try {
const all = readFileSync(join(runDir(work, id), "run.log"), "utf8").split("\n");
return all.slice(-Math.max(1, lines)).join("\n");
} catch {
return "";
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["tools/index.ts", "tools/runs.ts"]
}
+1 -1
View File
@@ -14,7 +14,7 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
# The filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the # The filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the
# legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168). # legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168).
# The container runs on the machine's network with NET_ADMIN (ADR 0169), so these act on the # The container runs on the machine's network with NET_ADMIN (ADR 0170), so these act on the
# machine's packet filter, not on a namespace of their own. # machine's packet filter, not on a namespace of their own.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends nftables iptables \ && apt-get install -y --no-install-recommends nftables iptables \
+1 -1
View File
@@ -2,7 +2,7 @@
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module // rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module
// loads it through its own unit. This code reads the filter back as the machine enforces it, reloads // loads it through its own unit. This code reads the filter back as the machine enforces it, reloads
// the mesh's own table, and removes one thing the mesh did not write when the operator names it // the mesh's own table, and removes one thing the mesh did not write when the operator names it
// (ADR 0168, ADR 0169) — the seat's three verbs, over the machine's own tools. // (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@novox/module-nftables", "name": "@novox/module-nftables",
"version": "0.1.0", "version": "0.1.0",
"description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0169).", "description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0170).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
// The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces, // The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces,
// reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of // reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of
// the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0169). // the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0170).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { FirewallClient } from "../client.js"; import { FirewallClient } from "../client.js";
+93
View File
@@ -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 };
}
}
+46
View File
@@ -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"
]
}
]
}
}
+14
View File
@@ -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"
}
}
+71
View File
@@ -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)));
+15
View File
@@ -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"
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"module": "zsh",
"version": "1",
"capabilities": [
"package-manager"
],
"seats": [
{
"name": "login-shell",
"scope": "node",
"serves": [
{
"name": "execute",
"description": "Run one command on this machine as the operator account, in a login shell; answers with what it printed and how it exited (novox/hq ADR 0176).",
"input": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "the command line, as you would type it"
},
"timeout_seconds": {
"type": "number",
"description": "give up after this long (default 60)"
}
},
"required": [
"command"
]
}
}
]
}
],
"claims": [
{
"name": "login-shell",
"scope": "node",
"serves": [
"execute"
]
}
],
"tools": [
"zsh_config"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "zsh"
},
{
"id": "rc",
"type": "file",
"path": "${machine:account-home}/.zshrc",
"owner": "${machine:account}",
"mode": "0644",
"into": "block",
"content": "# The mesh's default zsh configuration (module zsh). Everything OUTSIDE this block is yours and\n# survives every push; everything inside it is replaced on the next one (novox/hq ADR 0174).\n# Machine-specific lines go in ~/.zshrc.local, which this sources last.\n\nexport EDITOR=vim\nexport VISUAL=vim\nexport XDG_CONFIG_HOME=\"$HOME/.config\"\nexport PATH=\"$HOME/.local/bin:$HOME/scripts:$HOME/scripts/bin:$PATH\"\n\n# Terminal title: host, directory, git branch\nfunction set_terminal_title() {\n local git_branch=\"\"\n if git rev-parse --is-inside-work-tree &>/dev/null; then\n git_branch=\" ($(git branch --show-current 2>/dev/null))\"\n fi\n print -Pn \"\\e]2;%m: %~${git_branch}\\a\"\n}\nprecmd_functions+=(set_terminal_title)\n\n# A prompt theme and plugins, when a module placed them (the prompt module owns ~/.p10k.zsh and\n# ~/.zsh/themes; this only loads what is there).\n[[ ! -f ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh\n[[ ! -f ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n[[ ! -f ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n\n# Keybindings: Home, End, Ctrl-A, Ctrl-E, Del\nbindkey \"^[[H\" beginning-of-line\nbindkey \"^[OH\" beginning-of-line\nbindkey \"^A\" beginning-of-line\nbindkey \"^[[F\" end-of-line\nbindkey \"^[OF\" end-of-line\nbindkey \"^E\" end-of-line\nbindkey \"^[[3~\" delete-char\n\n# Colour and the usual ls aliases\nif [ -x /usr/bin/dircolors ]; then\n test -r \"$HOME/.dircolors\" && eval \"$(dircolors -b \"$HOME/.dircolors\")\" || eval \"$(dircolors -b)\"\n alias ls='ls --color=auto'\n alias grep='grep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\nalias drun='docker run -it --rm'\ndisksize() { du -h --max-depth=1 \"${1:-.}\" | sort -h; }\n\n# Machine-specific configuration, kept by you\n[[ ! -f ~/.zshrc.local ]] || source ~/.zshrc.local\n"
},
{
"id": "login",
"type": "user",
"name": "${machine:account}",
"shell": "/usr/bin/zsh"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-zsh",
"version": "0.1.0",
"description": "zsh \u2014 the shell as a module: the package, the mesh's default ~/.zshrc as a block the operator's own lines survive around, the login-shell seat and its execute verb (novox/hq ADR 0176).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+98
View File
@@ -0,0 +1,98 @@
// zsh's tools — the module's own, and its implementation of the login-shell seat's one verb
// (novox/hq ADR 0176). Served by the node tools runtime (ADR 0175); nothing here runs a process.
//
// `execute` runs as the operator account. The runtime runs as the node's account — root when the
// host started it — so the command is handed to the account through `runuser` when we are not
// already that account. Root is the module's concern (ADR 0175 §4): a command that needs it uses
// sudo inside the shell like a person would.
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
/** The operator account on this machine, as the mesh told the runtime; the current user otherwise. */
function account(env: NodeJS.ProcessEnv): string {
return env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().username;
}
interface Executed {
command: string;
account: string;
status: number | null;
signal: string | null;
stdout: string;
stderr: string;
timed_out: boolean;
}
/** Run one command line in a zsh login shell as the account, capturing everything. */
export async function execute(command: string, who: string, timeoutSeconds: number): Promise<Executed> {
const self = userInfo().username;
const argv = who === self
? ["zsh", "-lc", command]
: ["runuser", "-u", who, "--", "zsh", "-lc", command];
return new Promise((resolve) => {
const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let timedOut = false;
child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutSeconds * 1000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({ command, account: who, status: null, signal: null, stdout, stderr: stderr + err.message, timed_out: false });
});
child.on("close", (status, signal) => {
clearTimeout(timer);
resolve({ command, account: who, status, signal, stdout, stderr, timed_out: timedOut });
});
});
}
function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] {
return [
{
name: "execute",
description: "Run one command on this machine as the operator account, in a login shell; answers with what it printed and how it exited.",
input: {
type: "object",
properties: {
command: { type: "string", description: "the command line, as you would type it" },
timeout_seconds: { type: "number", description: "give up after this long (default 60)" },
},
required: ["command"],
},
run: async (args) => {
const command = String(args.command ?? "").trim();
if (!command) throw new Error("execute: a command is required");
const timeout = Number(args.timeout_seconds ?? 60);
return execute(command, account(env), Number.isFinite(timeout) && timeout > 0 ? timeout : 60);
},
},
];
}
function ownTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
return [
{
name: "zsh_config",
description: "The operator account's ~/.zshrc on this machine as it is now: the mesh's block and the lines around it.",
input: { type: "object", properties: {} },
run: async () => {
const who = account(env);
const home = env.MESH_OPERATOR_HOME?.trim() || (who === userInfo().username ? homedir() : `/home/${who}`);
const path = `${home}/.zshrc`;
const text = await readFile(path, "utf8").catch(() => "");
const inBlock = /# BEGIN mesh [^\n]*\n([\s\S]*?)# END mesh/.exec(text);
return { account: who, path, lines: text.split("\n").length, mesh_block_lines: inBlock ? inBlock[1].split("\n").length - 1 : 0, content: text };
},
},
];
}
// The seat's verb is registered under the seat's name (what the runtime serves on the seat's
// subject when this module holds it) and the module's own tools under the module's.
registerModuleTools("login-shell", seatVerbs);
registerModuleTools("zsh", ownTools);
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"tools/index.ts"
]
}