Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab58e79fb | ||
|
|
a263bc36ba | ||
|
|
d5c5415756 | ||
|
|
6dfd2401c9 | ||
|
|
31923f70e7 | ||
|
|
8cd4f199f1 | ||
|
|
1b9b298827 | ||
|
|
bce7b3a551 | ||
|
|
17d3d3e63a | ||
|
|
5c961c446f | ||
|
|
b77582f6a6 | ||
|
|
b3865d240f | ||
|
|
a81b94d4ab | ||
|
|
67d1a400e8 | ||
|
|
1c201d59c9 |
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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,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,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"tools/index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user