Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d7b9ad5a | ||
|
|
5548b0f4e9 |
@@ -59,10 +59,7 @@
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh-registry:/var/lib/registry"
|
||||
],
|
||||
"env": {
|
||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/api.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
+15
-5
@@ -303,11 +303,21 @@ export class MinioClient {
|
||||
|
||||
// --- module-scoped helpers -------------------------------------------------
|
||||
|
||||
// **Neither the access key nor the bucket is derived here any more.** `accessKeyFor` minted an id
|
||||
// of its own until the mesh took that over (ADR 0048: the login is the mesh's, handed to both
|
||||
// ends), and `bucketFor` derived the bucket until the mesh took that over too (ADR 0201: the rule
|
||||
// is a line of this module's manifest, filled per consumer and delivered to both ends). Both
|
||||
// survived with no callers, which is the state a rule comes back from; they are gone.
|
||||
/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state:
|
||||
* the provisioner recomputes the same id at teardown that it minted at creation. */
|
||||
export function accessKeyFor(consumer: string): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const digest = createHash("sha256").update(consumer).digest();
|
||||
let out = "";
|
||||
for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */
|
||||
export function bucketFor(consumer: string): string {
|
||||
const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
||||
return name.length >= 3 ? name : `mesh-${name}`;
|
||||
}
|
||||
|
||||
function bucketPolicy(bucket: string): string {
|
||||
return JSON.stringify({
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
"s3-bucket": {
|
||||
"scheme": "http",
|
||||
"region": "eu-west",
|
||||
"port": 9000,
|
||||
"bucket": "${consumer:as:dns}"
|
||||
"port": 9000
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.7"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -10,24 +10,18 @@
|
||||
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh
|
||||
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
|
||||
// creates the service account under exactly that access key with exactly that secret — a credential
|
||||
// the provisioner invented is one the consumer could never present.
|
||||
//
|
||||
// **The bucket name is the mesh's too (ADR 0201).** It used to be computed here, from the login,
|
||||
// and every consumer transcribed the same rule into its own definition by hand — two copies of
|
||||
// one rule with nothing comparing them, and one of three was wrong for months. Now the rule is a
|
||||
// line of this module's manifest (`serves.s3-bucket.bucket: ${consumer:as:dns}`), the mesh fills
|
||||
// it per consumer, and the same filled value reaches this provisioner and the consumer's own
|
||||
// configuration. There is no second computation to disagree with.
|
||||
// the provisioner invented is one the consumer could never present. The bucket is derived from the
|
||||
// login, so teardown recomputes it with nothing to persist.
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MinioClient } from "../client.js";
|
||||
import { MinioClient, bucketFor } from "../client.js";
|
||||
|
||||
const minio = MinioClient.fromEnv();
|
||||
|
||||
runProvisioner("s3-bucket", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const bucket = bucketNamed(p.derived);
|
||||
const bucket = bucketFor(p.as);
|
||||
const accessKeyId = p.as;
|
||||
|
||||
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
|
||||
@@ -44,8 +38,8 @@ runProvisioner("s3-bucket", {
|
||||
});
|
||||
},
|
||||
|
||||
async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
|
||||
const bucket = bucketNamed(p.derived);
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const bucket = bucketFor(p.as);
|
||||
|
||||
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
|
||||
// empty; a bucket that still holds objects is left for an operator rather than erroring on every
|
||||
@@ -63,28 +57,10 @@ runProvisioner("s3-bucket", {
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return minio.canReachAs(bucketNamed(p.derived), p.as, p.password);
|
||||
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
/** The bucket the mesh derived for this consumer.
|
||||
*
|
||||
* Absent means this module is running against a control plane that does not fill `${consumer:…}`
|
||||
* yet, or a manifest whose `serves` block lost the line. Both are the same mistake from here —
|
||||
* nobody said which bucket — and both are said rather than guessed: a provisioner that fell back
|
||||
* to deriving one would restore the second rule and hide the fault behind a bucket that happens
|
||||
* to be right. */
|
||||
function bucketNamed(derived: Readonly<Record<string, unknown>>): string {
|
||||
const bucket = derived.bucket;
|
||||
if (typeof bucket !== "string" || bucket === "") {
|
||||
throw new Error(
|
||||
"the mesh did not say which bucket this consumer gets: minio's manifest must serve " +
|
||||
"`bucket` under s3-bucket (novox/hq ADR 0201)",
|
||||
);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
||||
* bucket that was made. */
|
||||
async function announce(type: string, body: unknown): Promise<void> {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket}\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=mesh-novox-ncloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||
},
|
||||
{
|
||||
"id": "html",
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
@@ -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