Compare commits

..
Author SHA1 Message Date
jschoubben 7b09125d18 minio declares the bucket it derives; its consumers stop transcribing it (hq ADR 0188)
serves.s3-bucket.bucket is ${consumer:as:dns}; the provisioner uses what it
is given. nextcloud, invoicing and photos ask for ${bound:s3-bucket:bucket}
instead of naming mesh-novox-* literals, which also named this node.
bucketFor and the long-dead accessKeyFor are gone.
2026-10-02 21:25:30 +02:00
123 changed files with 3176 additions and 1834 deletions
+22
View File
@@ -0,0 +1,22 @@
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/anthropic-consumer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
# No serve-time entrypoints: every container of this module names its command (`run` on a
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
+62 -26
View File
@@ -14,10 +14,19 @@
"secrets": {
"model-access": "${dir:state}/access-token"
},
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"emits": [
"usage.session"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -37,39 +46,66 @@
},
{
"id": "apply",
"type": "process",
"name": "anthropic-consumer-apply",
"artifact": "code",
"run": [
"node",
"apply/index.js"
],
"type": "container",
"name": "mesh-anthropic-consumer-apply",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/apply/index.js"
],
"volumes": [
"${dir:state}:/run/state"
],
"env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
}
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
},
"artifact": "runtime"
},
{
"id": "usage",
"type": "container",
"name": "mesh-anthropic-consumer-usage",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/usage/index.js"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
"MESH_TOOLS_MAIN": "/app/dist/main.js"
},
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"apply/index.js",
"usage/index.js"
],
"loads": [
"usage/index.js"
],
"env": {
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+18 -19
View File
@@ -3,15 +3,12 @@
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
// attribution is done — just the totals (port map "don't-map" #3).
//
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
// the runtime as this module; the totals are also written to a file so the reading is observable
// without one.
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { emit } from "@novox/mesh-sdk/events";
import { readSessionFile, type SessionUsage } from "../transcript.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
@@ -119,20 +116,22 @@ function atomicWrite(path: string, content: string): void {
renameSync(tmp, path);
}
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
async function emitUsage(body: Record<string, unknown>): Promise<void> {
try {
await emit("usage.session", body);
} catch (err) {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
}
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => {
const child = spawn(
process.execPath,
[main, "emit", "usage.session", JSON.stringify(body)],
{ stdio: "inherit" },
);
child.on("exit", () => resolve());
child.on("error", (err) => {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
resolve();
});
});
}
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
// runtime's handshake is answered while a long first reading is still under way.
const EVERY_MS = 5 * 60 * 1000;
const tick = (): void => {
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
};
tick();
setInterval(tick, EVERY_MS);
await main();
+33
View File
@@ -0,0 +1,33 @@
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The toolkit is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings laid out beside it.
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the toolkit it will run against.
WORKDIR /app/modules/audit-logger
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
# imports anything — `run` exists for a step that works offline and exits, and would leave this
# with nothing to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
+33 -12
View File
@@ -5,21 +5,27 @@
"consumes": [
"**"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js"
],
"loads": [
"index.js"
],
"env": {
"AUDIT_LOG": "${dir:trail}/audit.log"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
},
@@ -34,6 +40,21 @@
"id": "trail",
"type": "directory",
"mode": "0700"
},
{
"id": "run",
"type": "container",
"name": "mesh-audit-logger",
"network": "host",
"volumes": [
"${dir:state}/broker:/run/secrets/broker:ro",
"${dir:trail}:/trail"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"AUDIT_LOG": "/trail/audit.log"
},
"artifact": "runtime"
}
],
"capabilities": [
+2 -4
View File
@@ -15,7 +15,7 @@ test("audit-logger records every event to the trail as one line each", async ()
const path = join(dir, "audit.log");
// The audit-logger's whole behaviour: consume everything, record it.
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes
await on("**", async (event) => record(event, path));
process.env.MESH_MODULE = "umami";
process.env.MESH_NODE = "anchor";
@@ -24,9 +24,7 @@ test("audit-logger records every event to the trail as one line each", async ()
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 2);
// A module names its events locally (design 29); the module is the `source`, which together with
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
assert.equal(lines[0].source, "umami");
assert.equal(lines[0].node, "anchor");
assert.equal(lines[0].body.domain, "my-app");
+24
View File
@@ -0,0 +1,24 @@
# baserow's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/baserow
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/baserow/dist /app/modules/baserow/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/baserow/dist/tools/index.js
+36 -14
View File
@@ -25,7 +25,8 @@
"postgres-database": "${dir:state}/database.secret"
},
"own-secrets": {
"admin": "${dir:state}/admin.secret"
"admin": "${dir:state}/admin.secret",
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
@@ -91,24 +92,45 @@
"mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-baserow",
"network": "baserow",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BASEROW_URL": "http://baserow:80",
"MESH_BASEROW_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
-15
View File
@@ -1,15 +0,0 @@
# build-agent
The mesh's build machine as a role every machine can hold (novox/hq ADR 0190). It holds the node seat
`node-build-agent`: every holder pulls one build at a time from the role's one work queue when it is
idle, so a tier of many images is built by as many machines as hold the seat and are online, and a
machine that is off builds nothing and blocks nothing. The controller asks the role, never a machine;
the outcome names the machine that built it.
What a holding machine needs is what the builder always needed, said here once: a container runtime
(the socket is mounted), the artifact store and the package registry as provisions, a workspace, and
the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from
that repository's main (`build.artifacts[].context`); this module ships the packaging.
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
same thing; retire that once this is assigned where it was.
@@ -1,6 +1,6 @@
ARG GO_BASE
ARG ALPINE_BASE
# build-agent's image: the build machine itself, compiled into a container (novox/hq ADR 0190).
# builder's own image: the build machine itself, compiled into a container.
#
# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder,
# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one
@@ -1,14 +1,13 @@
{
"module": "build-agent",
"module": "builder",
"version": "1",
"slug": "agent",
"capabilities": [
"container-runtime"
],
"claims": [
{
"name": "node-build-agent",
"scope": "node"
"name": "mesh-build-machine",
"scope": "mesh"
}
],
"requires": [
@@ -37,19 +36,19 @@
"mode": "0700"
},
{
"id": "agent-env",
"id": "builder-env",
"type": "file",
"path": "${dir:mesh-state}/build-agent.env",
"path": "${dir:mesh-state}/builder.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
},
{
"id": "server",
"type": "container",
"name": "mesh-build-agent",
"name": "mesh-builder",
"artifact": "server",
"env-file": [
"${dir:mesh-state}/build-agent.env"
"${dir:mesh-state}/builder.env"
],
"volumes": [
"${dir:mesh-state}:/run/mesh:ro",
@@ -57,7 +56,7 @@
"/var/run/docker.sock:/var/run/docker.sock"
],
"restart-on": [
"agent-env"
"builder-env"
],
"network": "host"
}
+24
View File
@@ -0,0 +1,24 @@
# cloudflare-dns's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/cloudflare-dns
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/cloudflare-dns/dist /app/modules/cloudflare-dns/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/cloudflare-dns/dist/tools/index.js,/app/modules/cloudflare-dns/dist/provisioner/index.js
+42 -17
View File
@@ -18,13 +18,20 @@
"public-dns": "${dir:grants}/mesh.json"
},
"own-secrets": {
"token": "${dir:state}/token"
"token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
},
"emits": [
"record.created",
"record.removed"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -43,30 +50,48 @@
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-cloudflare-dns",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:grants}:/grants",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json"
},
"artifact": "runtime"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token",
"MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# confluence's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/confluence
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/confluence/dist /app/modules/confluence/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/confluence/dist/tools/index.js
+40 -14
View File
@@ -3,9 +3,16 @@
"version": "1",
"slug": "confl",
"own-secrets": {
"token": "${dir:state}/token"
"token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -19,27 +26,46 @@
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token",
"MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+1 -4
View File
@@ -59,10 +59,7 @@
],
"volumes": [
"/var/lib/mesh-registry:/var/lib/registry"
],
"env": {
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
}
]
}
]
}
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
# fail2ban's runtime: the tool runtime, carrying the intrusion prevention's verbs and the client they
# speak through.
#
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
# module.json's `build.on`: the image this is compiled in and the image it runs in.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/fail2ban
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# The daemon runs on the machine, declared by this module; what runs here is only its client, which
# speaks to the daemon over the socket the machine shares into this container (novox/hq ADR 0179).
# The package brings the client and the daemon together; the daemon is never started here.
RUN apt-get update \
&& apt-get install -y --no-install-recommends fail2ban \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/fail2ban/dist /app/modules/fail2ban/dist
ENV MESH_TOOL_MODULES=/app/modules/fail2ban/dist/tools/index.js
+8 -40
View File
@@ -5,15 +5,12 @@
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
// mesh composes the jails and never writes the ban list.
//
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
// package this module declares on the machine, and the socket is root's: root is the module's
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
// Spoken through fail2ban-client over the daemon's socket, which the machine shares into this
// runtime; so the client here is the one from the runtime's own package and the daemon is the
// machine's, and the two meet at /var/run/fail2ban/fail2ban.sock.
import { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { isIP } from "node:net";
import { delimiter, join } from "node:path";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
@@ -21,44 +18,16 @@ const execFileP = promisify(execFile);
/** A command runner, so the verbs can be tested without a daemon. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** The command as it is run: as given when this process is root, else through sudo without a
* prompt. The daemon's socket answers only to root. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** Whether a tool is on this machine: an executable of that name on the path, or where the
* system keeps its administration. */
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
return dirs.some((dir) => {
try {
accessSync(join(dir, tool), constants.X_OK);
return true;
} catch {
return false;
}
});
}
export const execRunner: Runner = async (cmd, args) => {
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
} catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
// on its own stderr line, and the rest is the client's own answer.
if (program === "sudo") {
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
}
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
if (e.code === "ENOENT") throw new Error(`${cmd} is not in this runtime`);
if (/Failed to access socket path|Is fail2ban running/i.test(said)) {
throw new Error("fail2ban is not running on this machine, or its socket is not shared with this runtime");
}
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -107,8 +76,7 @@ export class Fail2banClient {
this.run = run;
}
/** The daemon as this machine has it, through its own client. */
static onThisMachine(): Fail2banClient {
static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient {
return new Fail2banClient();
}
+38 -6
View File
@@ -19,6 +19,9 @@
"tools": [
"fail2ban_settings"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"jailing": {
"into": "/etc/fail2ban/jail.d/mesh.conf",
"filter-into": "/etc/fail2ban/filter.d"
@@ -53,6 +56,12 @@
"path": "/var/run/fail2ban",
"mode": "0755"
},
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "jail-local",
"type": "file",
@@ -109,17 +118,40 @@
"action-dualchain",
"composed-jails"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-fail2ban",
"artifact": "runtime",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/run/fail2ban:/var/run/fail2ban"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker"
}
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
}
}
+1 -9
View File
@@ -2,7 +2,7 @@
// on the control node on 2026-10-02 (novox/hq ADR 0179).
import { test } from "node:test";
import assert from "node:assert/strict";
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
import { Fail2banClient, parseBans, parseJailStatus, type Runner } from "../client.ts";
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
const RECIDIVE =
@@ -104,11 +104,3 @@ test("a jail's settings are read from the daemon's listings", async () => {
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
});
});
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-client-of-the-mesh"), false);
});
+1 -1
View File
@@ -55,7 +55,7 @@ export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
];
}
const fail2ban = Fail2banClient.onThisMachine();
const fail2ban = Fail2banClient.fromEnv();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
// module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
+37
View File
@@ -0,0 +1,37 @@
# gitea's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
# consumer.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings.
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/gitea
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not
# through a CLI the way postgres drives psql — so the runtime base holds everything this needs.
COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist
# What a tool host should load from this module: its event consumer and its tools, which are
# separate entrypoints because they are loaded by different things. The provisioner is the third,
# and is not listed here — the declaration names it in the container's `args`, because it is what
# this module's own container runs. One image, because they are one module and share a client.
ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js,/app/modules/gitea/dist/provisioner/index.js
+45 -22
View File
@@ -85,6 +85,9 @@
"scope": "mesh"
}
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
@@ -177,6 +180,32 @@
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-gitea",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:runtime-state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_GITEA_STATE_DIR": "/run/state",
"MESH_RECEIVES": "${dir:grants}/npm.json"
},
"artifact": "runtime",
"restart-on": [
"runtime-config"
]
}
],
"provides": [
@@ -190,36 +219,30 @@
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
"MESH_RECEIVES": "${dir:grants}/npm.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
},
"jails": [
{
"name": "gitea",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
+24
View File
@@ -0,0 +1,24 @@
# gitlab's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/gitlab
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/gitlab/dist /app/modules/gitlab/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/gitlab/dist/tools/index.js
+40 -14
View File
@@ -2,9 +2,16 @@
"module": "gitlab",
"version": "1",
"own-secrets": {
"token": "${dir:state}/token"
"token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -18,27 +25,46 @@
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-gitlab",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "/run/secrets/token",
"MESH_GITLAB_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "${dir:state}/token",
"MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# grafana's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/grafana
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/grafana/dist /app/modules/grafana/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/grafana/dist/index.js,/app/modules/grafana/dist/tools/index.js
+36 -16
View File
@@ -5,7 +5,8 @@
"alert.firing"
],
"own-secrets": {
"admin": "${dir:mesh-state}/admin"
"admin": "${dir:mesh-state}/admin",
"broker": "${dir:mesh-state}/broker"
},
"capabilities": [
"container-runtime"
@@ -111,6 +112,25 @@
"mode": "0600",
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-grafana",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"requires": [
@@ -142,23 +162,23 @@
"influxdb-api": "${dir:mesh-state}/influxdb-api"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# home-assistant's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/home-assistant
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/home-assistant/dist /app/modules/home-assistant/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js
# NOT dist/provisions/index.js: that is a step the host runs to completion, named by the
# `provisions` container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run
# inside the serving sidecar too, and exit it.
+61 -33
View File
@@ -9,6 +9,7 @@
"state.changed"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker",
"token": "${dir:mesh-state}/token"
},
"listens": [
@@ -79,27 +80,55 @@
"merge": "json"
},
{
"id": "provisions-env",
"type": "file",
"path": "${dir:state}/provisions.env",
"mode": "0600",
"content": "MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123}\nMESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n"
"id": "runtime",
"type": "container",
"name": "mesh-home-assistant",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "provisions",
"type": "process",
"name": "home-assistant-provisions",
"artifact": "code",
"run": [
"node",
"provisions/index.js"
],
"type": "container",
"name": "mesh-home-assistant-provisions",
"network": "host",
"run-once": true,
"env-file": [
"${dir:state}/provisions.env"
"volumes": [
"${dir:mesh-state}/token:/run/secrets/token:ro",
"${dir:written}:/var/lib/home-assistant-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/sonarr-api.json:/run/provisions/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/provisions/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/provisions/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/provisions/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/provisions/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/provisions/lidarr-api.secret:ro"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/home-assistant-provisions"
},
"args": [
"run",
"/app/modules/home-assistant/dist/provisions/index.js"
],
"restart-on": [
"provisions-env",
"bound-mqtt-topic",
"secret-mqtt-topic",
"bound-sonarr-api",
@@ -108,7 +137,8 @@
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
]
],
"artifact": "runtime"
}
],
"requires": [
@@ -143,25 +173,23 @@
"lidarr-api": "${dir:state}/lidarr-api.secret"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisions/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# icecast's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/icecast
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/icecast/dist /app/modules/icecast/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/icecast/dist/index.js,/app/modules/icecast/dist/tools/index.js
+37 -15
View File
@@ -28,6 +28,9 @@
"stream.started",
"stream.stopped"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
"name": "stream",
@@ -92,26 +95,45 @@
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-icecast",
"network": "icecast",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ICECAST_URL": "http://icecast:8000",
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}",
"MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# influxdb's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/influxdb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
+39 -17
View File
@@ -11,6 +11,7 @@
"container-runtime"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker",
"admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret"
},
@@ -99,6 +100,29 @@
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-influxdb",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"requires": [
@@ -111,25 +135,23 @@
}
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# jira's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/jira
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/jira/dist /app/modules/jira/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/jira/dist/tools/index.js
+40 -14
View File
@@ -2,9 +2,16 @@
"module": "jira",
"version": "1",
"own-secrets": {
"token": "${dir:state}/token"
"token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -18,27 +25,46 @@
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-jira",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "/run/secrets/token",
"MESH_JIRA_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "${dir:state}/token",
"MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+30
View File
@@ -0,0 +1,30 @@
# keycloak's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/keycloak
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
+40 -20
View File
@@ -62,7 +62,8 @@
"oidc-client": "${dir:grants}"
},
"own-secrets": {
"admin": "${dir:state}/admin.secret"
"admin": "${dir:state}/admin.secret",
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
@@ -143,30 +144,49 @@
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-keycloak",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:grants}:${dir:grants}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+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
+45 -56
View File
@@ -5,7 +5,16 @@
"container-runtime",
"virtualisation"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -26,67 +35,47 @@
"content": "MESH_LAB_FORGE=${setting:forge}\n"
},
{
"id": "git",
"type": "package",
"package": "git"
},
{
"id": "make",
"type": "package",
"package": "make"
},
{
"id": "python",
"type": "package",
"package": "python"
},
{
"id": "file",
"type": "package",
"package": "file"
},
{
"id": "iproute2",
"type": "package",
"package": "iproute2"
},
{
"id": "sudo",
"type": "package",
"package": "sudo"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "incus",
"type": "package",
"package": "incus"
"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": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_LAB_WORK": "${dir:work}",
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+1 -23
View File
@@ -2,23 +2,18 @@
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
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";
// The forge is an operator's setting, which reaches a file and never a bundle's words (novox/hq
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
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 () => {
const forge = forgeOf();
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]);
@@ -43,7 +38,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
},
},
run: async (args) => {
const forge = forgeOf();
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" };
@@ -89,20 +83,4 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
];
}
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
export function wordIn(file: string | undefined, word: string): string {
if (!file) return "";
let text: string;
try {
text = readFileSync(file, "utf8");
} catch {
return "";
}
for (const line of text.split("\n")) {
const at = line.indexOf("=");
if (at > 0 && line.slice(0, at).trim() === word) return line.slice(at + 1).trim();
}
return "";
}
registerModuleTools("lab", (env) => getLabTools(env));
+24
View File
@@ -0,0 +1,24 @@
# letta's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/letta
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/letta/dist /app/modules/letta/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/letta/dist/tools/index.js
+36 -14
View File
@@ -26,7 +26,8 @@
},
"own-secrets": {
"server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret"
"openai-api-key": "${dir:state}/openai-api-key.secret",
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
@@ -83,24 +84,45 @@
"mode": "0600",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-letta",
"network": "letta",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_LETTA_URL": "http://127.0.0.1:${port:8283}",
"MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+30
View File
@@ -0,0 +1,30 @@
# mailu's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mailu
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
+41 -30
View File
@@ -135,15 +135,11 @@
"protocol": "tcp",
"from": "mesh",
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
},
{
"name": "admin-api",
"port": 8080,
"protocol": "tcp",
"from": "machine",
"why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network"
}
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
@@ -309,9 +305,6 @@
"name": "mailu-admin",
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
"network": "mailu",
"ports": [
"8080"
],
"env-file": [
"${dir:state}/mailu.env",
"${dir:state}/secret.env",
@@ -480,6 +473,31 @@
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mailu",
"network": "mailu",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "automx",
"type": "container",
@@ -499,6 +517,16 @@
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "PYTHON_BASE",
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
@@ -506,26 +534,9 @@
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
},
{
"name": "automx",
+55
View File
@@ -0,0 +1,55 @@
# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer
# of what the builder announces, and its tools.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation with the siblings.
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/mesh-catalog
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **A module may need something the base image does not carry.** The base holds what every module
# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that
# reaches a database shells out to psql instead. So the catalogue brings its own.
#
# Installed into an empty directory rather than into the module's, because the module's package.json
# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to
# resolve this module's dependencies would therefore fail on the one it already has.
RUN mkdir -p /deps && cd /deps && \
npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist
# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the
# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it
# was compiled against.
COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# Both entrypoints, loaded in serve mode.
#
# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a
# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `on()` has something to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
# here, beside the entrypoints above, because the module knows which of its files prepares its state
# and nothing else could: the mesh asks one word and this says what answers it.
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
+36 -24
View File
@@ -25,6 +25,9 @@
"secrets": {
"postgres-database": "${dir:state}/database.secret"
},
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"consumes": [
"mesh-build-machine.built",
"mesh-controller.built-before"
@@ -35,7 +38,14 @@
"rebuild-needed",
"catching-up"
],
"prepares": true,
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -50,41 +60,43 @@
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
},
{
"id": "prepare",
"type": "process",
"name": "mesh-catalog-prepare",
"artifact": "code",
"run": [
"node",
"prepare/index.js"
"id": "runtime",
"type": "container",
"name": "mesh-catalog",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}:/run/state",
"${dir:state}/database.url:/run/secrets/database-url:ro"
],
"run-once": true,
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
"MESH_BROKER_FILE": "/run/secrets/broker",
"DATABASE_URL_FILE": "/run/secrets/database-url"
},
"artifact": "runtime",
"restart-on": [
"database-url"
]
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"prepare/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+3 -3
View File
@@ -1,9 +1,9 @@
// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg`
// package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses —
// the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts,
// listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the
// builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the
// code without deciding what runs.
// listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's
// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without
// deciding what runs.
declare module "pg" {
/** One checked-out connection. Needed because registering a module-version and its edges is one
* act: a half-written registration is a graph that lies about what something was built against. */
+2 -3
View File
@@ -7,9 +7,8 @@
// nothing anywhere said so.
//
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
// there is nothing yet to talk to: the host runs this file as a run-once process, with the module's
// words and no bus (novox/hq ADR 0198 §3), before the node's runtime is started with the version
// that needs it, and this process exiting non-zero is how the host knows the step did not complete.
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
// process exiting non-zero is how the host knows not to start the runtime.
import { Graph } from "../store.js";
const graph = Graph.fromEnv();
+13
View File
@@ -0,0 +1,13 @@
# The console (novox/hq ADR 0152, design 34): the mesh's tools for whoever is on a machine, served
# over MCP on that machine's loopback.
#
# **Nothing is compiled here.** The console is the tool runtime's own client — `mesh serve` — which
# the runtime image already carries beside the runtime it runs modules with. This recipe changes the
# program the image starts and nothing else, so the console is exactly the client a person can run by
# hand, started by the mesh instead, on the credential the mesh sealed to the machine.
#
# One base, named rather than pinned: the mesh answers with the copy it holds (novox/hq issue 044).
ARG RUNTIME_BASE
FROM ${RUNTIME_BASE}
ENTRYPOINT ["node", "dist/mesh.js"]
+38
View File
@@ -0,0 +1,38 @@
# mesh-console
The mesh's tools, on the machine a person sits at, served by a module the mesh assigned there
(novox/hq [ADR 0152](https://git.novox.be/novox/hq), design 34).
Assign it to a machine and an agent on that machine has the mesh's tools at
`http://127.0.0.1:<port>/mcp` — MCP over HTTP, `initialize`, `tools/list`, `tools/call`. A person at
a terminal reaches the same endpoint with `mesh tools --console http://127.0.0.1:<port>` and
`mesh call <module>.<tool> --console …`, with no credential of their own: the console holds it.
## What it is
The tool runtime's own client, `mesh serve`, started by the mesh on the credential it sealed to the
machine for `<node>.mesh-console`. The manifest says three things nothing else in the catalogue says
together:
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
- a listener `from: machine` — loopback only, and the filter opens nothing for it;
- no `emits`, no `consumes`, no `tools` — nothing on the bus can address it.
**Loopback is the authority boundary.** Whoever can connect is on the machine, and whoever is on the
machine is the account that owns the mesh there (ADR 0034, ADR 0144). There is no token and no login,
and `mesh serve` refuses to bind anything but a loopback address.
## What it lists
What the running modules answer: every tool runtime serves a `tools` verb for its module, and the
console asks the catalogue which modules the mesh holds and each module what it serves. A module that
did not answer — not assigned, not up, or built before the runtime answered `tools` — is named in the
list's `_meta.notAnswering` and can still be called by `<module>.<tool>`.
The mesh's own verbs (`status`, `push`, `assign`) are the `mesh-controller` seat's tools under
ADR 0132 and are not served on the bus yet; they appear here when they are.
## Port
The manifest declares port 4270 and the mesh assigns the machine port as it does for any listener;
the console binds `127.0.0.1:${port:4270}`. `node show <machine>` says which port a machine was given.
+64
View File
@@ -0,0 +1,64 @@
{
"module": "mesh-console",
"version": "1",
"slug": "console",
"capabilities": [
"container-runtime"
],
"invokes": [
"*"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
"name": "mcp",
"port": 4270,
"protocol": "tcp",
"from": "machine",
"why": "the mesh's tools for whoever is on this machine, over MCP on loopback; the machine's login is the authority (novox/hq ADR 0152)"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "server",
"type": "container",
"name": "mesh-console",
"network": "host",
"args": [
"serve"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}"
},
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+22
View File
@@ -0,0 +1,22 @@
# mesh-vault's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
# consumer. The same shape as postgres's, minus the client the database needs: mesh-vault reaches no
# server, because what it provides is a value the mesh already delivered to its node.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout (novox/hq ADR 0069). Two bases, named rather than
# pinned — the image this is COMPILED in and the image it RUNS in — answered by the mesh from
# `build.on` in module.json (novox/hq issue 044).
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/vault
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/vault/dist /app/modules/vault/dist
# The entrypoints a tool host loads from this module: its event consumer, its tools and its
# provisioner — one image, one process, one broker account (novox/hq ADR 0052).
ENV MESH_TOOL_MODULES=/app/modules/vault/dist/index.js,/app/modules/vault/dist/tools/index.js,/app/modules/vault/dist/provisioner/index.js
+43 -18
View File
@@ -27,7 +27,16 @@
"secret": "${dir:grants}"
},
"keeps": "/var/lib/mesh-vault/root",
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -48,29 +57,45 @@
"id": "root",
"type": "directory",
"mode": "0700"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-vault",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:ledger}:${dir:ledger}",
"${dir:root}:${dir:root}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
},
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
},
+40
View File
@@ -0,0 +1,40 @@
# minio's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
ARG MC_CLI
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
FROM ${MC_CLI} AS mccli
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/minio
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/minio/dist /app/modules/minio/dist
# The provisioner shells out to mc to actually create buckets and service accounts on the running
# minio server — mc itself was never in this runtime image, only in minio's own. Silently retried
# "spawn mc ENOENT" forever: a requirement was granted at the control-plane level without ever
# materializing the credential on minio. /usr/bin/mc there is a symlink to the real binary, mcli —
# both copied so the symlink resolves.
COPY --from=mccli /usr/bin/mcli /usr/bin/mcli
COPY --from=mccli /usr/bin/mc /usr/bin/mc
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/minio/dist/tools/index.js,/app/modules/minio/dist/provisioner/index.js
+1 -1
View File
@@ -305,7 +305,7 @@ export class MinioClient {
// **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 0202: the rule
// ends), and `bucketFor` derived the bucket until the mesh took that over too (ADR 0188: 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.
+45 -24
View File
@@ -60,9 +60,16 @@
"s3-bucket": "${dir:grants}"
},
"own-secrets": {
"root": "${dir:state}/root.secret"
"root": "${dir:state}/root.secret",
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -121,34 +128,48 @@
}
},
{
"id": "client",
"type": "package",
"package": "minio-client"
"id": "runtime",
"type": "container",
"name": "mesh-minio",
"network": "minio-net",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_MINIO_ENDPOINT": "http://minio:9000",
"MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MESH_MINIO_REGION": "eu-west",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "MC_CLI",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_MINIO_ENDPOINT": "http://127.0.0.1:${port:9000}",
"MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "${dir:state}/root.secret",
"MESH_MINIO_REGION": "eu-west",
"MESH_MINIO_MC_BIN": "mcli",
"MESH_MINIO_MC_CONFIG": "${dir:state}/mc",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.7"
"@novox/mesh-sdk": "^0.1.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
+2 -2
View File
@@ -12,7 +12,7 @@
// 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 0202).** It used to be computed here, from the login,
// **The bucket name is the mesh's too (ADR 0188).** 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
@@ -79,7 +79,7 @@ function bucketNamed(derived: Readonly<Record<string, unknown>>): string {
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 0202)",
"`bucket` under s3-bucket (novox/hq ADR 0188)",
);
}
return bucket;
+37
View File
@@ -0,0 +1,37 @@
# mongodb's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mongodb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# mongodb's client shells out to `mongosh`, installed from MongoDB's own apt repo so its shared
# libraries come with it — copying the bare binary out of the mongo image leaves it unable to load.
RUN apt-get update && apt-get install -y --no-install-recommends gnupg curl ca-certificates \
&& curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" > /etc/apt/sources.list.d/mongodb.list \
&& apt-get update && apt-get install -y --no-install-recommends mongodb-mongosh \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/mongodb/dist /app/modules/mongodb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mongodb/dist/index.js,/app/modules/mongodb/dist/tools/index.js,/app/modules/mongodb/dist/provisioner/index.js
+79 -70
View File
@@ -1,16 +1,19 @@
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mongodb does.
//
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code
// now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the
// server through the official `mongodb` driver its package.json names — installed and inlined into
// the bundle by the builder. One connection per call, as one mongosh invocation was: the module is
// called rarely, and a pool held open across calls would hold a credential the mesh may rotate.
// Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency
// beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface
// than this should carry — so it shells out to the shell the mongodb image ships, the same way
// postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary,
// `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and
// its result comes back as EJSON on stdout.
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface DatabaseInfo {
readonly name: string;
@@ -56,26 +59,32 @@ export class MongoClient {
return this.conn.port;
}
/** The admin connection URI, credentials percent-encoded. */
/** The admin connection URI mongosh authenticates with, credentials percent-encoded. */
private uri(): string {
const u = encodeURIComponent(this.conn.user);
const p = encodeURIComponent(this.conn.password);
const a = encodeURIComponent(this.conn.authSource);
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`;
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`;
}
/**
* The one execution boundary: connect as the administrator, do `work`, and close — a failure to
* connect or to authenticate rejects here rather than returning a partial success.
* Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see
* header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method
* below ends in `print(EJSON.stringify(...))`. `--quiet` suppresses the shell banner so stdout is
* the JSON alone; a non-zero exit (auth failure, bad command) rejects here rather than returning
* a partial success.
*/
private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> {
const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 });
try {
await client.connect();
return await work(client);
} finally {
await client.close();
async evalJs<T>(js: string): Promise<T> {
const { stdout } = await run(
"mongosh",
[this.uri(), "--quiet", "--eval", js],
{ maxBuffer: 16 << 20 },
);
const text = stdout.trim();
if (text.length === 0) {
throw new Error("mongosh returned no output — the eval printed nothing");
}
return JSON.parse(text) as T;
}
/**
@@ -85,16 +94,19 @@ export class MongoClient {
* password and roles, so a rotated credential converges.
*/
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
await this.admin(async (client) => {
const target = client.db(database);
const roles = [{ role: "dbOwner", db: database }];
const found = await target.command({ usersInfo: user });
if (Array.isArray(found.users) && found.users.length > 0) {
await target.command({ updateUser: user, pwd: password, roles });
} else {
await target.command({ createUser: user, pwd: password, roles });
}
});
const js = `
const target = db.getSiblingDB(${lit(database)});
let existing = null;
try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; }
const roles = [{ role: "dbOwner", db: ${lit(database)} }];
if (existing) {
target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles });
} else {
target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles });
}
print(EJSON.stringify({ ok: 1 }));
`;
await this.evalJs<{ ok: number }>(js);
}
/**
@@ -103,43 +115,45 @@ export class MongoClient {
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Credentials as options, never in a URI, so the consumer's password is in no message a failed
// connection prints.
const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, {
auth: { username: user, password },
authSource: database,
serverSelectionTimeoutMS: 10_000,
});
// Connected without credentials, then authenticated inside the eval from the environment, so
// the consumer's password is neither on argv nor in the message of a failed command.
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
const js =
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
let stdout: string;
try {
await client.connect();
const status = await client.db(database).command({ connectionStatus: 1 });
const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database);
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
timeout: 30_000,
}));
} catch (err) {
if (isAuthFailure(err)) return false;
throw new Error(`mongodb could not check ${user}: ${String((err as Error).message).split("\n")[0]}`);
} finally {
await client.close();
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
}
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database);
}
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => {
const target = client.db(database);
try {
await target.command({ dropUser: user });
} catch (err) {
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
}
await target.dropDatabase();
});
const js = `
const target = db.getSiblingDB(${lit(database)});
try { target.dropUser(${lit(user)}); } catch (e) {}
target.dropDatabase();
print(EJSON.stringify({ ok: 1 }));
`;
await this.evalJs<{ ok: number }>(js);
}
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
async listDatabases(): Promise<DatabaseInfo[]> {
const res = await this.admin((client) => client.db("admin").admin().listDatabases());
const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>(
`print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`,
);
return (res.databases ?? [])
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
.sort((a, b) => a.name.localeCompare(b.name));
@@ -148,8 +162,6 @@ export class MongoClient {
/**
* Run a read-only `find` against a collection in a named database, for the mongodb_query tool.
* `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result.
* Documents come back as relaxed Extended JSON — an ObjectId as `{"$oid": …}` — exactly as the
* shell's `EJSON.stringify` rendered them before.
*/
async find(
database: string,
@@ -158,29 +170,26 @@ export class MongoClient {
limit: number,
): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000));
const docs = await this.admin((client) =>
client
.db(database)
.collection(collection)
.find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document)
.limit(capped)
.toArray(),
);
return BSON.EJSON.serialize(docs, { relaxed: true }) as Record<string, unknown>[];
const js =
`print(EJSON.stringify(` +
`db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` +
`.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` +
`));`;
return this.evalJs<Record<string, unknown>[]>(js);
}
}
/** An authentication failure, as the server or the driver reports it. */
function isAuthFailure(err: unknown): boolean {
if (err instanceof MongoServerError && err.code === 18) return true; // 18: AuthenticationFailed
return /Authentication failed|AuthenticationFailed/i.test(String((err as Error)?.message ?? ""));
}
/** Generate a URL-safe password. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
/** Embed a value as a JavaScript literal inside a mongosh snippet — JSON.stringify escapes quotes,
* backslashes and control characters, so a string cannot break out of the snippet. */
function lit(val: unknown): string {
return JSON.stringify(val);
}
function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
+3 -3
View File
@@ -1,9 +1,9 @@
// mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner
// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mongodb.database.provisioned — a consumer's database + owning user was created
// module.mongodb.database.deprovisioned — that database was removed
// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events";
+43 -28
View File
@@ -39,9 +39,17 @@
"mongodb-database": "${dir:grants}"
},
"own-secrets": {
"root": "${dir:state}/root.secret"
"root": "${dir:state}/root.secret",
"broker": "${dir:mesh-state}/broker"
},
"secrets-owner": "999:999",
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -63,14 +71,6 @@
"type": "network",
"name": "mongodb"
},
{
"id": "server-root",
"type": "file",
"path": "${dir:state}/server-root.secret",
"mode": "0400",
"owner": "999:999",
"content": "${secret:root}"
},
{
"id": "server",
"type": "container",
@@ -86,31 +86,46 @@
],
"volumes": [
"${dir:data}:/data/db",
"${dir:state}/server-root.secret:/run/secrets/root:ro"
"${dir:state}/root.secret:/run/secrets/root:ro"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mongodb",
"network": "mongodb",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+1 -2
View File
@@ -5,8 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.1",
"mongodb": "^6.21.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+2 -1
View File
@@ -11,7 +11,8 @@
// same-named database under exactly that login — a name the consumer cannot learn is a database it
// cannot reach.
//
// The commands run through MongoClient, the official driver inside this bundle (see client.ts).
// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see
// client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
+2 -2
View File
@@ -1,6 +1,6 @@
// mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They
// return structured data; the mesh serves them through the sdk's tool harness. Both call the server
// through MongoClient, the driver inside this bundle (see client.ts).
// return structured data; the mesh serves them through the sdk's tool harness. Both call through
// MongoClient.evalJs(), the module's one execution boundary (see client.ts).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MongoClient } from "../client.js";
+28
View File
@@ -0,0 +1,28 @@
# mosquitto's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/mosquitto
COPY . .
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# mosquitto's client and bootstrap drive `mosquitto_ctrl`; the apt package carries it with its
# shared libraries — the musl binary from the eclipse image would not load on this glibc base.
RUN apt-get update && apt-get install -y --no-install-recommends mosquitto \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/mosquitto/dist /app/modules/mosquitto/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/mosquitto/dist/index.js,/app/modules/mosquitto/dist/tools/index.js,/app/modules/mosquitto/dist/provisioner/index.js
+4 -36
View File
@@ -18,7 +18,6 @@ import { randomBytes } from "node:crypto";
import { connect as tcpConnect } from "node:net";
import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { basename, dirname } from "node:path";
import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
@@ -31,14 +30,6 @@ export interface MqttConn {
/** The Dynamic Security admin client the runtime authenticates as. */
readonly adminUser: string;
readonly adminPassword: string;
/**
* The broker's own container, when `mosquitto_ctrl` is run inside it rather than from this
* machine's packages. The broker's image carries the tool at the broker's version, and inside it
* the broker listens on 127.0.0.1:1883 whatever port the machine publishes.
*/
readonly container?: string;
/** The broker's image, to seed the security file before the broker has ever started. */
readonly image?: string;
}
export class MosquittoClient {
@@ -62,11 +53,7 @@ export class MosquittoClient {
"mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker",
);
}
return new MosquittoClient({
host, port, adminUser, adminPassword: adminPassword ?? "",
container: env.MESH_MQTT_CTRL_CONTAINER || undefined,
image: env.MESH_MQTT_CTRL_IMAGE || undefined,
});
return new MosquittoClient({ host, port, adminUser, adminPassword: adminPassword ?? "" });
}
get host(): string {
@@ -97,18 +84,16 @@ export class MosquittoClient {
* to this single-purpose runtime container; see the module README.
*/
async ctl(...args: string[]): Promise<string> {
const inside = this.conn.container !== undefined;
const base = [
"-h", inside ? "127.0.0.1" : this.conn.host,
"-p", inside ? "1883" : String(this.conn.port),
"-h", this.conn.host,
"-p", String(this.conn.port),
"-u", this.conn.adminUser,
"-P", this.conn.adminPassword,
];
let stdout: string;
let stderr: string;
try {
const [command, argv] = this.ctrl([...base, "dynsec", ...args]);
({ stdout, stderr } = await run(command, argv, {
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
maxBuffer: 16 << 20,
timeout: 30_000,
}));
@@ -255,27 +240,10 @@ export class MosquittoClient {
async initBootstrapFile(configFile: string): Promise<void> {
// `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does
// not connect to the broker. The password is a positional argument (omitting it prompts).
if (this.conn.image) {
// Before the broker has ever started there is no container to enter: a throwaway one from the
// broker's own image writes the file into the directory the broker will mount.
await run("docker", [
"run", "--rm", "--entrypoint", "mosquitto_ctrl",
"-v", `${dirname(configFile)}:/mosquitto/data`,
this.conn.image,
"dynsec", "init", `/mosquitto/data/${basename(configFile)}`, this.conn.adminUser, this.conn.adminPassword,
], { maxBuffer: 16 << 20 });
return;
}
await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], {
maxBuffer: 16 << 20,
});
}
/** How `mosquitto_ctrl` is run here: inside the broker's container when one is named. */
ctrl(argv: string[]): [string, string[]] {
if (this.conn.container) return ["docker", ["exec", this.conn.container, "mosquitto_ctrl", ...argv]];
return ["mosquitto_ctrl", argv];
}
}
/** Generate a URL-safe password with no argv- or MQTT-hostile characters. */
+51 -40
View File
@@ -32,7 +32,8 @@
"mqtt-topic": "${dir:grants}"
},
"own-secrets": {
"admin": "${dir:mesh-state}/admin"
"admin": "${dir:mesh-state}/admin",
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
@@ -87,29 +88,26 @@
"type": "network",
"name": "mosquitto"
},
{
"id": "bootstrap-env",
"type": "file",
"path": "${dir:state}/bootstrap.env",
"mode": "0600",
"content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\n"
},
{
"id": "bootstrap",
"type": "process",
"type": "container",
"name": "mosquitto-bootstrap",
"artifact": "code",
"run": [
"node",
"bootstrap/index.js"
],
"run-once": true,
"env-file": [
"${dir:state}/bootstrap.env"
"volumes": [
"${dir:data}:/mosquitto/data",
"${dir:mesh-state}/admin:/run/secrets/admin:ro"
],
"restart-on": [
"bootstrap-env"
]
"env": {
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin",
"MESH_DYNSEC_FILE": "/mosquitto/data/dynamic-security.json"
},
"args": [
"run",
"/app/modules/mosquitto/dist/bootstrap/index.js"
],
"artifact": "runtime"
},
{
"id": "server",
@@ -125,32 +123,45 @@
"${dir:data}:/mosquitto/data",
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mosquitto",
"network": "mosquitto",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:mesh-state}/admin:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
},
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js",
"bootstrap/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin",
"MESH_MQTT_CTRL_CONTAINER": "mosquitto"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
-19
View File
@@ -1,19 +0,0 @@
// Run after `npm run build`.
// mosquitto_ctrl runs inside the broker's own container when the manifest names it, so a machine
// needs no mosquitto package (whose index may be too stale to install from) and the tool always
// matches the broker's version.
import assert from "node:assert/strict";
import { test } from "node:test";
import { MosquittoClient } from "../dist/client.js"; // compiled: client.ts uses parameter properties, which type stripping cannot run
const env = { MESH_PROVISION_MQTT: "127.0.0.1:21883", MESH_MQTT_PASSWORD: "pw" };
test("named, the broker's container runs mosquitto_ctrl", () => {
const c = MosquittoClient.fromEnv({ ...env, MESH_MQTT_CTRL_CONTAINER: "mosquitto" });
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["docker", ["exec", "mosquitto", "mosquitto_ctrl", "dynsec", "listClients"]]);
});
test("unnamed, this machine's mosquitto_ctrl runs", () => {
const c = MosquittoClient.fromEnv(env);
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["mosquitto_ctrl", ["dynsec", "listClients"]]);
});
+43
View File
@@ -0,0 +1,43 @@
# mssql's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mssql
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed
# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked
# against its digest, so a build that receives anything else stops here.
FROM ${BUILD_BASE} AS sqlcmd
ARG SQLCMD_VERSION=v1.10.0
ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \
&& curl -fsSL -o /tmp/sqlcmd.tar.bz2 \
"https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \
&& echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \
&& tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd
FROM ${RUNTIME_BASE}
COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd
COPY --from=build /app/modules/mssql/dist /app/modules/mssql/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js
+98 -111
View File
@@ -1,74 +1,22 @@
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mssql does.
//
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's
// runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the
// `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One
// boundary, `session()`, and every method is built on it: a connection as one login to one database,
// opened for one call and closed after, as one sqlcmd invocation was.
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
// protocol is more surface than this should carry — so it shells out to the client the mssql
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
// through doveadm. One boundary, `run()`, and every method is built on it.
//
// Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's
// answer is shaped exactly as it was: SQL Server owns the quoting and typing.
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
// column-aligned text, since SQL Server owns the quoting and typing.
import { isIP } from "node:net";
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import sql from "mssql";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
/** Where a session connects, and as whom. */
export interface Target {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
readonly database: string;
}
/** One login's connection to one database: run a batch, answer the rows of its last result set. */
export interface Session {
/** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */
run(text: string, params?: Record<string, string>): Promise<Record<string, unknown>[]>;
close(): Promise<void>;
}
/** How a session is opened — the driver, or a test's fake. */
export type Connect = (to: Target) => Promise<Session>;
/**
* The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what
* sqlcmd's `-C` did), one connection, closed with the session.
*/
export const connectWithDriver: Connect = async (to) => {
const pool = new sql.ConnectionPool({
server: to.host,
port: to.port,
user: to.user,
password: to.password,
database: to.database,
// TLS names a host, never an address: Node refuses an IP as the server name (DEP0123, an error
// since Node 25), and the module reaches its server on loopback. The certificate is trusted
// either way, so the name only has to be one TLS accepts.
options: { encrypt: true, trustServerCertificate: true, ...(isIP(to.host) ? { serverName: "localhost" } : {}) },
pool: { min: 0, max: 1 },
connectionTimeout: 15_000,
requestTimeout: 60_000,
});
await pool.connect();
return {
async run(text, params = {}) {
const request = pool.request();
const names = Object.keys(params);
for (const name of names) request.input(name, sql.NVarChar, params[name]);
// A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a
// parameterised query (sp_executesql) is not.
const result = names.length > 0 ? await request.query(text) : await request.batch(text);
const sets = (result.recordsets ?? []) as Record<string, unknown>[][];
return sets.length > 0 ? sets[sets.length - 1] : [];
},
close: () => pool.close(),
};
};
const run = promisify(execFile);
export interface QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
@@ -97,17 +45,20 @@ export interface MssqlConn {
*/
export const READER = "mesh_mssql_reader";
/** Who a session logs in as. */
/** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */
interface Invocation {
readonly user: string;
readonly password: string;
/**
* A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's
* environment — the administrator's password among it. (Its own commands are kept out by the
* caller's text never beginning a line; see readOnlyQuery.)
*/
readonly caller: boolean;
}
export class MssqlClient {
constructor(
private readonly conn: MssqlConn,
private readonly connect: Connect = connectWithDriver,
) {}
constructor(private readonly conn: MssqlConn) {}
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
private readerReady?: Promise<void>;
@@ -139,41 +90,63 @@ export class MssqlClient {
return this.conn.port;
}
/** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */
async exec(text: string, database = "master"): Promise<void> {
await this.session(text, database);
/**
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
* failed statement an error here rather than a success with a warning, and `-C` trusts the
* server's self-signed certificate the mssql image ships with.
*/
async exec(sql: string, database = "master"): Promise<void> {
await this.sqlcmd(sql, database);
}
/**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers
* (split across rows for a large result, and reassembled here) is parsed. An empty result yields
* no rows — an empty array. `params` are bound as `@name`, never written into the text.
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
* prints (split across output lines for a large result, and reassembled here) is parsed. An
* empty result yields no output at all — an empty array.
*/
async query(
select: string,
database = "master",
params: Record<string, string> = {},
variables: Record<string, string> = {},
): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
return parseJsonRows(await this.session(wrapped, database, params));
const stdout = await this.sqlcmd(wrapped, database, variables);
return parseJsonRows(stdout);
}
/** The one execution boundary: open a session as `as`, run `text`, close it. */
private async session(
text: string,
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
private async sqlcmd(
sql: string,
database: string,
params: Record<string, string> = {},
as: Invocation = { user: this.conn.user, password: this.conn.password },
): Promise<Record<string, unknown>[]> {
const session = await this.connect({
host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database,
});
try {
return await session.run(text, params);
} finally {
await session.close();
}
variables: Record<string, string> = {},
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false },
): Promise<string> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
// with postgres's — the module owns its own code (ADR 0039) and shells out to it.
const { stdout } = await run(
"sqlcmd",
[
"-S", `${this.conn.host},${this.conn.port}`,
"-U", as.user,
"-d", database,
...(as.caller ? ["-x"] : []),
"-C",
"-b",
"-h", "-1",
"-y", "0",
"-Y", "0",
"-W",
"-Q", sql,
],
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
// variables: a value that must not appear on argv, or in the message of a failed command.
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: as.password }, maxBuffer: 16 << 20 },
);
return stdout;
}
/**
@@ -200,7 +173,7 @@ export class MssqlClient {
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
);
if (dbs.length === 0) {
// CREATE DATABASE must stand alone in its batch; it runs as its own session.
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
await this.exec(`CREATE DATABASE ${ident(database)}`);
}
@@ -233,14 +206,15 @@ export class MssqlClient {
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password is a bound parameter, never inside the query text, so it is in no message of a
// failed statement.
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
// minted value, which carries no quote.
const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(@meshholdspw, password_hash) = 1) ` +
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master",
{ meshholdspw: password },
{ MESHHOLDSPW: password },
);
if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
@@ -320,27 +294,35 @@ export class MssqlClient {
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the
* rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
* is refused.
*
* The text goes to the server as it is, over the driver: there is no client between that reads a
* line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this
* process's environment, so neither the one-line rule nor `-x` has anything left to guard.
*/
async readOnlyQuery(database: string, text: string): Promise<QueryResult> {
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
const password = this.conn.readerPassword;
if (!password) throw readerMissing();
// **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own
// command rather than SQL, and `:!!` starts a program in this container, which holds the
// administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the
// version shipped here, so instead no line of a caller's text can begin one: the text follows
// this module's own on the first line, and a line break in it is refused. Proven on a throwaway
// server: the same text at the start of a line ran a program; mid-line it is a syntax error.
if (/[\r\n]/.test(sql)) {
throw new Error(
"mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " +
"'!!' as a command of its own, which can start a program (novox/hq issue 193)",
);
}
this.readerReady ??= this.ensureReader().catch((err) => {
this.readerReady = undefined; // asked again next call, not failed for the process's life
throw err;
});
await this.readerReady;
const rows = await this.session(
`SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
const stdout = await this.sqlcmd(
`SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
database,
{},
{ user: READER, password },
{ user: READER, password, caller: true },
);
const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseJsonRows(rows) };
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseJsonRows(stdout) };
}
}
@@ -390,13 +372,18 @@ function safeUrl(raw: string): URL | undefined {
}
/**
* Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into
* ~2033-character chunks, one per row of a single column, so the document is reassembled by
* concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none.
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
* a pure DDL batch) means no rows.
*/
function parseJsonRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join("");
if (joined.trim().length === 0) return [];
function parseJsonRows(stdout: string): Record<string, unknown>[] {
const joined = stdout
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
}
+3 -3
View File
@@ -1,9 +1,9 @@
// mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner
// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mssql.database.provisioned — a consumer's database + login/user was created
// module.mssql.database.deprovisioned — that database was removed
// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events";
+42 -19
View File
@@ -38,9 +38,16 @@
},
"own-secrets": {
"sa": "${dir:state}/sa.secret",
"broker": "${dir:mesh-state}/broker",
"reader": "${dir:state}/reader.secret"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
@@ -86,30 +93,46 @@
"${dir:data}:/var/opt/mssql"
],
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mssql",
"network": "mssql",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mssql/grants:ro",
"${dir:state}/sa.secret:/run/secrets/sa:ro",
"${dir:state}/reader.secret:/run/secrets/reader:ro"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader"
},
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
-32
View File
@@ -1,32 +0,0 @@
// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This
// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module
// type-checks without deciding what runs: the real `mssql` is the package.json dependency the
// builder installs and inlines into the bundle (novox/hq ADR 0198 §4).
declare module "mssql" {
interface Result {
recordsets: unknown;
}
interface Request {
input(name: string, type: unknown, value: unknown): Request;
query(text: string): Promise<Result>;
batch(text: string): Promise<Result>;
}
class ConnectionPool {
constructor(config: {
server: string;
port?: number;
user?: string;
password?: string;
database?: string;
options?: { encrypt?: boolean; trustServerCertificate?: boolean; serverName?: string };
pool?: { min?: number; max?: number };
connectionTimeout?: number;
requestTimeout?: number;
});
connect(): Promise<ConnectionPool>;
request(): Request;
close(): Promise<void>;
}
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
export default sql;
}
+2 -3
View File
@@ -5,12 +5,11 @@
"type": "module",
"private": true,
"scripts": {
"build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1",
"mssql": "^11.0.2"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+64 -51
View File
@@ -1,83 +1,96 @@
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the
// reader login and never as the administrator, with no transaction wrapped around it as text, and
// without the reader's password the statement is refused.
// reader login and never as the administrator, with sqlcmd's variable substitution off, on one line
// that follows the module's own — a line break is refused before sqlcmd starts — and with no
// transaction wrapped around it as text. Without the reader's password the statement is refused.
//
// The driver is a fake session that records each call's login, database, text and bound
// parameters. That the reader cannot write is the server's to enforce and was proven against a real
// server; this holds the module to asking for it. Run against the compiled module (npm test builds
// first), the way the runtime loads it.
// sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot
// write is the server's to enforce and was proven against a real server; this holds the module to
// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it.
import { test } from "node:test";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js";
import { MssqlClient, READER } from "../dist/client.js";
interface Call extends Target {
text: string;
params: Record<string, string>;
}
let dir: string;
let log: string;
const originalPath = process.env.PATH;
function recording(): { connect: Connect; calls: Call[] } {
const calls: Call[] = [];
const connect: Connect = async (to) => ({
async run(text, params = {}) {
calls.push({ ...to, text, params });
if (/FROM sys.server_principals/.test(text)) return [];
// FOR JSON answers its document split across rows of one column.
if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }];
return [];
},
async close() {},
});
return { connect, calls };
before(async () => {
dir = await mkdtemp(join(tmpdir(), "mssql-reader-"));
log = join(dir, "calls.jsonl");
await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const at = (flag) => args[args.indexOf(flag) + 1];
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({
user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"),
password: process.env.SQLCMDPASSWORD,
}) + "\\n");
const sql = at("-Q");
if (/FROM sys.server_principals/.test(sql)) process.stdout.write("");
else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n');
`);
await chmod(join(dir, "sqlcmd"), 0o755);
process.env.PATH = `${dir}:${originalPath}`;
});
after(async () => {
process.env.PATH = originalPath;
await rm(dir, { recursive: true, force: true });
});
async function calls(): Promise<Record<string, unknown>[]> {
const text = await readFile(log, "utf8").catch(() => "");
await writeFile(log, "");
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
}
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" };
test("a caller's statement runs as the reader, as it was written, on the database it names", async () => {
const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
test("a caller's statement runs as the reader, without variables, on the module's first line", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p");
const asked = calls.at(-1)!;
const asked = (await calls()).at(-1)!;
assert.equal(asked.user, READER, "the statement never runs as the administrator");
assert.equal(asked.password, "reader-secret");
assert.equal(asked.database, "inventory");
assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"),
"the caller's text reaches the server unaltered");
assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled");
assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text");
const [first] = String(asked.sql).split("\n");
assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line");
assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]);
assert.equal(result.command, "SELECT");
});
test("a line break in a caller's statement is refused before sqlcmd starts", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) {
await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/);
}
assert.deepEqual(await calls(), []);
});
test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => {
const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
await client.readOnlyQuery("inventory", "SELECT 1 AS x");
await client.readOnlyQuery("inventory", "SELECT 2 AS x");
const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text);
const made = await calls();
const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql));
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`)));
assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s)));
assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`));
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`));
assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call");
assert.equal(calls.filter((c) => c.user === READER).length, 2);
assert.equal(made.filter((c) => c.user === READER).length, 2);
});
test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => {
const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
const client = new MssqlClient(conn);
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/);
assert.deepEqual(calls, []);
});
test("a consumer's password is checked as a bound parameter, never in the text", async () => {
const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
await client.holdsLogin("shop", "shop_login", "minted-secret");
const asked = calls[0];
assert.equal(asked.params.meshholdspw, "minted-secret");
assert.doesNotMatch(asked.text, /minted-secret/);
assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/);
assert.deepEqual(await calls(), []);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}
-173
View File
@@ -1,173 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"math"
"net"
"sort"
"strconv"
"strings"
"time"
)
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
const (
DefaultTimeout = 3 * time.Second
MostTimeout = 30 * time.Second
)
// TCPResult is what netcheck_tcp answers.
type TCPResult struct {
Host string `json:"host"`
Port int `json:"port"`
Address string `json:"address,omitempty"`
Reachable bool `json:"reachable"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
// that does not answer is a result, not a failure of the tool; only a malformed question is.
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
if port < 1 || port > 65535 {
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
}
out := TCPResult{Host: host, Port: port}
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
return out, nil
}
out.Address = conn.RemoteAddr().String()
out.Reachable = true
_ = conn.Close()
return out, nil
}
// DNSResult is what netcheck_dns answers.
type DNSResult struct {
Name string `json:"name"`
Type string `json:"type"`
Answers []string `json:"answers"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// DNSTypes are the record types netcheck_dns looks up.
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
// A name that does not resolve is a result with its error; an unknown type is refused.
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
kind = strings.ToUpper(strings.TrimSpace(kind))
if kind == "" {
kind = "A"
}
known := false
for _, t := range DNSTypes {
known = known || t == kind
}
if !known {
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
}
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := net.DefaultResolver
start := time.Now()
var err error
switch kind {
case "A", "AAAA":
network := "ip4"
if kind == "AAAA" {
network = "ip6"
}
var ips []net.IP
if ips, err = r.LookupIP(ctx, network, name); err == nil {
for _, ip := range ips {
out.Answers = append(out.Answers, ip.String())
}
}
case "CNAME":
var cname string
if cname, err = r.LookupCNAME(ctx, name); err == nil {
out.Answers = append(out.Answers, cname)
}
case "TXT":
var txts []string
if txts, err = r.LookupTXT(ctx, name); err == nil {
out.Answers = append(out.Answers, txts...)
}
case "MX":
var mxs []*net.MX
if mxs, err = r.LookupMX(ctx, name); err == nil {
for _, mx := range mxs {
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
}
}
}
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
}
if kind != "MX" {
sort.Strings(out.Answers)
}
return out, nil
}
// text is a required string argument.
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
func whole(args map[string]any, key string, fallback int) (int, error) {
v, given := args[key]
if !given || v == nil {
if fallback == 0 {
return 0, fmt.Errorf("%s is required", key)
}
return fallback, nil
}
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, errors.New(key + " must be a whole number")
}
// timeoutOf is timeout_ms, defaulted and bounded.
func timeoutOf(args map[string]any) (time.Duration, error) {
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
if err != nil {
return 0, err
}
if ms < 1 {
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
}
d := time.Duration(ms) * time.Millisecond
if d > MostTimeout {
d = MostTimeout
}
return d, nil
}
@@ -1,68 +0,0 @@
package main
import (
"net"
"testing"
"time"
)
func TestATCPPortThatListensIsReachableAndOneThatDoesNotIsNot(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := l.Addr().(*net.TCPAddr).Port
got, err := CheckTCP("127.0.0.1", port, time.Second)
if err != nil || !got.Reachable || got.Error != "" {
t.Fatalf("a listening port: %+v, %v", got, err)
}
l.Close()
got, err = CheckTCP("127.0.0.1", port, time.Second)
if err != nil || got.Reachable || got.Error == "" {
t.Fatalf("a closed port is reported as a result with its error, not a failure: %+v, %v", got, err)
}
}
func TestAPortOutsideTheRangeIsRefused(t *testing.T) {
for _, p := range []int{0, -1, 65536} {
if _, err := CheckTCP("127.0.0.1", p, time.Second); err == nil {
t.Errorf("port %d was accepted", p)
}
}
}
func TestDNSAnswersFromTheMachinesResolverAndRefusesAnUnknownType(t *testing.T) {
got, err := CheckDNS("localhost", "a", time.Second)
if err != nil || got.Type != "A" || len(got.Answers) == 0 {
t.Fatalf("localhost A: %+v, %v", got, err)
}
if _, err := CheckDNS("localhost", "SRV", time.Second); err == nil {
t.Fatal("an unknown record type was accepted")
}
got, err = CheckDNS("no-such-name.invalid", "A", time.Second)
if err != nil || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("a name that does not resolve is a result with its error: %+v, %v", got, err)
}
}
func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
if d, _ := timeoutOf(map[string]any{}); d != DefaultTimeout {
t.Errorf("default: %v", d)
}
if d, _ := timeoutOf(map[string]any{"timeout_ms": float64(10 * 60 * 1000)}); d != MostTimeout {
t.Errorf("bounded: %v", d)
}
if _, err := timeoutOf(map[string]any{"timeout_ms": float64(0)}); err == nil {
t.Error("a zero timeout was accepted")
}
}
func TestBothToolsAreListedUnprefixed(t *testing.T) {
names := map[string]bool{}
for _, tool := range tools() {
names[tool.Name] = true
}
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
t.Fatalf("tools: %v", names)
}
}
-72
View File
@@ -1,72 +0,0 @@
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): netcheck.
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "netcheck_tcp",
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
"and closes it at once, sending nothing. Answers reachable, elapsed_ms and the error when not.",
Input: map[string]any{
"host": map[string]any{"type": "string", "description": "host name or IP address"},
"port": map[string]any{"type": "integer", "description": "TCP port, 1-65535"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
host, err := text(args, "host")
if err != nil {
return nil, err
}
port, err := whole(args, "port", 0)
if err != nil {
return nil, err
}
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckTCP(host, port, timeout)
},
},
{
Name: "netcheck_dns",
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
Input: map[string]any{
"name": map[string]any{"type": "string", "description": "the name to look up"},
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
name, err := text(args, "name")
if err != nil {
return nil, err
}
kind, _ := args["type"].(string)
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckDNS(name, kind, timeout)
},
},
}
}
-5
View File
@@ -1,5 +0,0 @@
module netcheck
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-84
View File
@@ -1,84 +0,0 @@
// netcheck's HTTP check — the module's own code, in TypeScript (novox/hq ADR 0039, ADR 0188). One
// request, HEAD or GET, never a body sent and never a body read: the status, how long it took and
// a few headers that say what answered. Redirects are reported, not followed, so a check reaches
// exactly the address it was given.
export const METHODS = ["HEAD", "GET"] as const;
export type Method = (typeof METHODS)[number];
/** The headers worth reporting: what answered and what it says it is, nothing it set for a client. */
export const REPORTED_HEADERS = [
"content-type", "content-length", "server", "location", "date",
"cache-control", "last-modified", "etag",
] as const;
export const DEFAULT_TIMEOUT_MS = 5000;
export const MOST_TIMEOUT_MS = 30000;
export interface HttpResult {
url: string;
method: Method;
status?: number;
statusText?: string;
elapsed_ms: number;
headers: Record<string, string>;
error?: string;
}
/** Only http and https are checked; anything else — file:, data:, ftp: — is refused by name. */
export function checkedUrl(raw: unknown): URL {
const text = typeof raw === "string" ? raw.trim() : "";
if (!text) throw new Error("url is required");
let url: URL;
try {
url = new URL(text);
} catch {
throw new Error(`${JSON.stringify(text)} is not a URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`netcheck_http checks http and https URLs only, not ${url.protocol}`);
}
return url;
}
export function checkedMethod(raw: unknown): Method {
const m = (typeof raw === "string" && raw.trim() ? raw.trim() : "HEAD").toUpperCase();
if (!(METHODS as readonly string[]).includes(m)) {
throw new Error(`method ${m} is not one netcheck_http uses (${METHODS.join(", ")}): a check never changes anything`);
}
return m as Method;
}
export function checkedTimeout(raw: unknown): number {
if (raw === undefined || raw === null || raw === "") return DEFAULT_TIMEOUT_MS;
const n = Number(raw);
if (!Number.isInteger(n) || n < 1) throw new Error(`timeout_ms must be a whole number of at least 1, not ${String(raw)}`);
return Math.min(n, MOST_TIMEOUT_MS);
}
/** Make one request and report how it went. A refused connection or a timeout is a result with its
* error; only a malformed question throws. */
export async function checkHttp(args: Readonly<Record<string, unknown>>, fetcher: typeof fetch = fetch): Promise<HttpResult> {
const url = checkedUrl(args.url);
const method = checkedMethod(args.method);
const timeout = checkedTimeout(args.timeout_ms);
const started = performance.now();
const out: HttpResult = { url: url.toString(), method, elapsed_ms: 0, headers: {} };
try {
const res = await fetcher(url, { method, redirect: "manual", signal: AbortSignal.timeout(timeout) });
out.elapsed_ms = Math.round(performance.now() - started);
out.status = res.status;
out.statusText = res.statusText;
for (const h of REPORTED_HEADERS) {
const v = res.headers.get(h);
if (v !== null) out.headers[h] = v;
}
// The body is not read: a check asks whether something answers, not what it says.
await res.body?.cancel().catch(() => {});
} catch (err) {
out.elapsed_ms = Math.round(performance.now() - started);
const e = err as Error & { cause?: { message?: string; code?: string } };
out.error = e.name === "TimeoutError" ? `no answer within ${timeout} ms` : (e.cause?.code ?? e.cause?.message ?? e.message);
}
return out;
}
-35
View File
@@ -1,35 +0,0 @@
{
"module": "netcheck",
"version": "1",
"tools": [
"netcheck_tcp",
"netcheck_dns",
"netcheck_http"
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/netcheck",
"binary": "netcheck",
"loads": [
"netcheck"
]
},
{
"name": "tools-typescript",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
]
}
]
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@novox/module-netcheck",
"version": "0.1.0",
"description": "netcheck — read-only network checks from a machine, as one module carrying a Go tools bundle (TCP, DNS) and a TypeScript one (HTTP) (novox/hq ADR 0188, ADR 0193).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.6"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-52
View File
@@ -1,52 +0,0 @@
// The HTTP check refuses what is not http(s) and what would change something, and reports a status,
// a refusal and a timeout as results (novox/hq ADR 0188: a tools bundle is read-only and harmless).
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { checkHttp, checkedMethod, checkedUrl } from "../http.ts";
test("only http and https URLs are checked", () => {
for (const bad of ["file:///etc/passwd", "ftp://example.org/", "data:text/plain,hi", "javascript:1", "", "not a url"]) {
assert.throws(() => checkedUrl(bad), `${bad} was accepted`);
}
assert.equal(checkedUrl("https://example.org/x").protocol, "https:");
});
test("only HEAD and GET are used", () => {
assert.equal(checkedMethod(undefined), "HEAD");
assert.equal(checkedMethod("get"), "GET");
for (const bad of ["POST", "PUT", "DELETE", "PATCH"]) assert.throws(() => checkedMethod(bad));
});
test("a status, its headers and a redirect not followed", async () => {
const server = createServer((req, res) => {
if (req.url === "/moved") { res.writeHead(302, { location: "/elsewhere" }); res.end(); return; }
res.writeHead(200, { "content-type": "text/plain", "x-secret": "not reported" });
res.end(req.method === "GET" ? "body" : undefined);
});
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
try {
const head = await checkHttp({ url: base + "/" });
assert.equal(head.status, 200);
assert.equal(head.method, "HEAD");
assert.equal(head.headers["content-type"], "text/plain");
assert.equal(head.headers["x-secret"], undefined);
const moved = await checkHttp({ url: base + "/moved", method: "GET" });
assert.equal(moved.status, 302);
assert.equal(moved.headers.location, "/elsewhere");
} finally {
server.close();
}
});
test("a refused connection is a result with its error", async () => {
const server = createServer();
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const port = (server.address() as AddressInfo).port;
await new Promise<void>((ok) => server.close(() => ok()));
const got = await checkHttp({ url: `http://127.0.0.1:${port}/`, timeout_ms: 2000 });
assert.equal(got.status, undefined);
assert.ok(got.error, "no error reported");
});
-28
View File
@@ -1,28 +0,0 @@
// netcheck's TypeScript tools bundle (novox/hq ADR 0188, ADR 0193): what the builder's launcher
// imports and serves over MCP on stdio. Its Go bundle serves the TCP and DNS checks; this one the
// HTTP check — one module, two languages, one runtime that knows neither.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { checkHttp, DEFAULT_TIMEOUT_MS, METHODS, MOST_TIMEOUT_MS } from "../http.js";
export function getNetcheckHttpTools(): ToolDefinition[] {
return [
{
name: "netcheck_http",
description:
"Check whether an http(s) URL answers from this machine: one HEAD or GET, no body sent or read, " +
"redirects reported and not followed. Answers status, elapsed_ms and a few headers.",
input: {
url: { type: "string", description: "an http:// or https:// URL" },
method: { type: "string", enum: [...METHODS], description: "HEAD (default) or GET" },
timeout_ms: {
type: "integer",
description: `give up after this long (default ${DEFAULT_TIMEOUT_MS}, at most ${MOST_TIMEOUT_MS})`,
},
},
run: async (args) => checkHttp(args),
},
];
}
registerModuleTools("netcheck", () => getNetcheckHttpTools());
+40
View File
@@ -0,0 +1,40 @@
# nextcloud's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
ARG DOCKER_CLI
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
FROM ${DOCKER_CLI} AS dockercli
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/nextcloud
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/nextcloud/dist /app/modules/nextcloud/dist
# occ runs inside nextcloud's own container, reached over the mounted docker socket — which needs
# the docker CLI itself present here, not only the socket. Copied from Docker's own official client
# image rather than apt-installed, so this stays the one binary and nothing else (no daemon, no
# systemd unit, no package manager tree pulled in for it).
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/nextcloud/dist/index.js,/app/modules/nextcloud/dist/tools/index.js
+44 -18
View File
@@ -30,7 +30,8 @@
"share.created"
],
"own-secrets": {
"admin": "${dir:state}/admin.secret"
"admin": "${dir:state}/admin.secret",
"broker": "${dir:mesh-state}/broker"
},
"capabilities": [
"container-runtime"
@@ -93,28 +94,53 @@
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nextcloud",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}",
"MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json",
"MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin",
"MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "/run/secrets/admin"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "DOCKER_CLI",
"image": "docker@sha256:018edbc908e08fcc9dbf029c812c34251e9b4719e6f71ca0e5eae2a987d014ca"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}",
"MESH_NEXTCLOUD_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin",
"MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# nftables' runtime: the tool runtime, carrying the packet filter's tools and the binaries they speak.
#
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
# module.json's `build.on`: the image this is compiled in and the image it runs in.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/nftables
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# 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).
# 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.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nftables iptables \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/nftables/dist /app/modules/nftables/dist
ENV MESH_TOOL_MODULES=/app/modules/nftables/dist/tools/index.js
+13 -71
View File
@@ -2,13 +2,9 @@
// 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
// the mesh's own table, and removes one thing the mesh did not write when the operator names it
// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools. Root is the module's
// concern (ADR 0175 §4): the runtime loading this bundle runs as the operator's account (to-be 38
// WP4), so the commands go through sudo without a prompt where the account is not root.
// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools.
import { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { delimiter, join } from "node:path";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
@@ -16,54 +12,9 @@ const execFileP = promisify(execFile);
/** A command runner, so the acts can be tested without a packet filter. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** Where the mesh writes this node's filter: the path the manifest's `filtering.into` names. A
* bundle has no environment of its own (to-be 38 WP4), so the path is said here once, and a test
* holds it to the manifest's. */
export const FILTER_FILE = "/etc/nftables.conf";
/** The command as it is run: as given when this process is root, else through sudo without a
* prompt. The packet filter answers only to root, listing included. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** Whether a tool is on this machine: an executable of that name on the path, or where the
* system keeps its administration. Asked before a tool is run, so "not here" and "refused" are
* never confused — the former is a fact to work around, the latter an error to say. */
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
return dirs.some((dir) => {
try {
accessSync(join(dir, tool), constants.X_OK);
return true;
} catch {
return false;
}
});
}
export const execRunner: Runner = async (cmd, args) => {
const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
} catch (err) {
// What failed is named by how it failed, not by prose: sudo missing is a spawn error; sudo
// refusing speaks on its own stderr line; anything else is the command's own failure.
const e = err as { code?: string | number; stderr?: string };
if (program === "sudo") {
if (e.code === "ENOENT") {
throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
}
const stderr = String(e.stderr ?? "").trim();
if (/^sudo: .*command not found/m.test(stderr)) throw new Error(`${cmd} is not installed here`);
if (/^sudo:/m.test(stderr)) {
throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${stderr}`);
}
}
throw err;
}
const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
};
/** The mesh's own tables, which `remove` never touches. */
@@ -83,17 +34,14 @@ export interface Removal {
export class FirewallClient {
private readonly run: Runner;
private readonly filterFile: string;
private readonly have: (tool: string) => boolean;
constructor(run: Runner = execRunner, filterFile: string = FILTER_FILE, have: (tool: string) => boolean = installed) {
constructor(run: Runner = execRunner, filterFile: string = process.env.MESH_FILTER_FILE ?? "/etc/nftables.conf") {
this.run = run;
this.filterFile = filterFile;
this.have = have;
}
/** The filter as this machine has it: its own tools, the mesh's file. */
static onThisMachine(): FirewallClient {
return new FirewallClient();
static fromEnv(env: NodeJS.ProcessEnv = process.env): FirewallClient {
return new FirewallClient(execRunner, env.MESH_FILTER_FILE ?? "/etc/nftables.conf");
}
/** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */
@@ -117,14 +65,11 @@ export class FirewallClient {
const legacy: Record<string, string> = {};
if (!table) {
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
if (!this.have(tool)) continue; // no legacy tool, nothing to list
try {
const out = await this.run(tool, ["-S"]);
if (out.trim()) legacy[tool] = out;
} catch (err) {
// The tool is here and would not answer: said, not swallowed — a listing that silently
// leaves out a predecessor's rules reads as "none".
legacy[tool] = `error: ${err instanceof Error ? err.message : String(err)}`;
} catch {
// the tool is not here, or the legacy filter is empty: nothing to list
}
}
}
@@ -137,17 +82,14 @@ export class FirewallClient {
return { loaded: this.filterFile, table: await this.ruleset() };
}
/** Whether the found front end is in force, whose chains `remove` leaves alone. Absent, it is
* not; present and not answering, nothing is removed on a guess. */
/** Whether the found front end is in force, whose chains `remove` leaves alone. */
private async ufwActive(): Promise<boolean> {
if (!this.have("ufw")) return false;
let out: string;
try {
out = await this.run("ufw", ["status"]);
} catch (err) {
throw new Error(`cannot tell whether the found firewall is in force, so nothing of its is removed: ${err instanceof Error ? err.message : String(err)}`);
const out = await this.run("ufw", ["status"]);
return /^Status:\s*active/m.test(out);
} catch {
return false;
}
return /^Status:\s*active/m.test(out);
}
/** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */
+45 -13
View File
@@ -2,7 +2,8 @@
"module": "nftables",
"version": "1",
"capabilities": [
"firewall"
"firewall",
"container-runtime"
],
"claims": [
{
@@ -19,16 +20,17 @@
"into": "/etc/nftables.conf"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "package",
"type": "package",
"package": "nftables"
},
{
"id": "legacy-tools",
"type": "package",
"package": "iptables"
},
{
"id": "unit",
"type": "file",
@@ -40,7 +42,7 @@
"id": "stock-unit-stop",
"type": "file",
"path": "/etc/systemd/system/nftables.service.d/mesh.conf",
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
"mode": "0644"
},
{
@@ -62,20 +64,50 @@
"type": "package",
"package": "ufw",
"absent": true
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nftables",
"network": "host",
"capabilities": [
"NET_ADMIN"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/etc/nftables.conf:/etc/nftables.conf:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_FILTER_FILE": "/etc/nftables.conf"
},
"artifact": "runtime"
}
],
"tools": [
"firewall_rules"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
+7 -41
View File
@@ -4,8 +4,7 @@
// and the same in an iptables-nft table. It refuses what is not the operator's to remove.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { FILTER_FILE, FirewallClient, chainsJumpingTo, escalated, installed, type Runner } from "../client.ts";
import { FirewallClient, chainsJumpingTo, type Runner } from "../client.ts";
const legacy = [
"-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT",
@@ -35,7 +34,7 @@ function fake(ufwActive = false): { run: Runner; asked: string[] } {
test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => {
const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain HAL-MESH-ONLY (iptables-legacy)");
const out = await new FirewallClient(f.run).remove("chain HAL-MESH-ONLY (iptables-legacy)");
assert.deepEqual(out.did, [
"iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY",
"iptables-legacy -F HAL-MESH-ONLY",
@@ -45,27 +44,27 @@ test("a predecessor's chain in the legacy filter loses its jumps, is flushed and
test("the runtime's user chain is emptied back to its one return, never deleted", async () => {
const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain DOCKER-USER (ip6tables-legacy)");
const out = await new FirewallClient(f.run).remove("chain DOCKER-USER (ip6tables-legacy)");
assert.deepEqual(out.did, ["ip6tables-legacy -F DOCKER-USER", "ip6tables-legacy -A DOCKER-USER -j RETURN"]);
const nft = await new FirewallClient(fake().run, undefined, () => true).remove("table ip6 filter, chain DOCKER-USER");
const nft = await new FirewallClient(fake().run).remove("table ip6 filter, chain DOCKER-USER");
assert.deepEqual(nft.did, ["ip6tables -F DOCKER-USER", "ip6tables -A DOCKER-USER -j RETURN"]);
});
test("a chain of the machine's own nftables table goes with the rules that reach it", async () => {
const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("table ip6 own, chain deny");
const out = await new FirewallClient(f.run).remove("table ip6 own, chain deny");
assert.deepEqual(out.did, ["nft delete rule ip6 own forward handle 7", "nft delete chain ip6 own deny"]);
});
test("what is not the operator's to remove is refused by name", async () => {
const c = new FirewallClient(fake(true).run, undefined, () => true);
const c = new FirewallClient(fake(true).run);
await assert.rejects(c.remove("table inet mesh, chain forward"), /the mesh's own table/);
await assert.rejects(c.remove("chain DOCKER (iptables-legacy)"), /container runtime's own/);
await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/);
await assert.rejects(c.remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"), /found firewall, which is in force/);
await assert.rejects(c.remove("something else"), /not a rule set as the host reports one/);
// Retired, a front end's leftover is nobody's and goes.
const retired = await new FirewallClient(fake(false).run, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
const retired = await new FirewallClient(fake(false).run).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
});
@@ -73,36 +72,3 @@ test("which chains jump to a target is read from a listing", () => {
const listing = "table ip6 own {\n\tchain a {\n\t\tjump deny\n\t}\n\tchain b {\n\t\tgoto deny\n\t}\n\tchain deny {\n\t\tdrop\n\t}\n}\n";
assert.deepEqual(chainsJumpingTo(listing, "deny"), ["a", "b"]);
});
test("the filter's commands run as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("nft", ["list", "ruleset"], 0), ["nft", ["list", "ruleset"]]);
assert.deepEqual(escalated("nft", ["-f", "/etc/nftables.conf"], 1000), ["sudo", ["-n", "nft", "-f", "/etc/nftables.conf"]]);
assert.deepEqual(escalated("iptables-legacy", ["-S"], undefined), ["sudo", ["-n", "iptables-legacy", "-S"]]);
});
test("the filter file is the one the manifest's filtering names", () => {
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { filtering: { into: string } };
assert.equal(FILTER_FILE, manifest.filtering.into);
});
test("a tool is installed when an executable of its name is on the path, and not otherwise", () => {
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-tool-of-the-mesh"), false);
});
test("a found firewall that is absent guards nothing; one that will not answer stops the removal", async () => {
// Absent: its leftover chain is nobody's and goes, without asking it.
const absent = fake(true);
const out = await new FirewallClient(absent.run, undefined, () => false).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
assert.ok(out.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
assert.ok(!absent.asked.some((a) => a.startsWith("ufw ")));
// Present and failing — refused by sudo, say — nothing is removed on a guess.
const refusing: Runner = async (cmd, args) => {
if (cmd === "ufw") throw new Error("ufw needs root and the runtime's account may not run it without a prompt");
return fake().run(cmd, args);
};
await assert.rejects(
new FirewallClient(refusing, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"),
/cannot tell whether the found firewall is in force/,
);
});
+1 -1
View File
@@ -44,7 +44,7 @@ export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
];
}
const firewall = FirewallClient.onThisMachine();
const firewall = FirewallClient.fromEnv();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
// module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));
+27
View File
@@ -0,0 +1,27 @@
# nodered's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/nodered
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/nodered/dist /app/modules/nodered/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/nodered/dist/tools/index.js
# NOT dist/mqtt/index.js: that is a step the host runs to completion, named by the `mqtt`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+55 -31
View File
@@ -12,7 +12,8 @@
"api-token": {
"path": "${dir:mesh-state}/api-token",
"taken": "at-start"
}
},
"broker": "${dir:mesh-state}/broker"
},
"capabilities": [
"container-runtime"
@@ -100,31 +101,53 @@
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
},
{
"id": "mqtt-env",
"type": "file",
"path": "${dir:state}/mqtt.env",
"mode": "0600",
"content": "MESH_NODERED_URL=http://127.0.0.1:${port:1880}\nMESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n"
"id": "runtime",
"type": "container",
"name": "mesh-nodered",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "mqtt",
"type": "process",
"name": "nodered-mqtt",
"artifact": "code",
"run": [
"node",
"mqtt/index.js"
],
"type": "container",
"name": "mesh-nodered-mqtt",
"network": "host",
"run-once": true,
"env-file": [
"${dir:state}/mqtt.env"
"volumes": [
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:written}:/var/lib/nodered-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/settings.json:/run/provisions/settings.json:ro"
],
"env": {
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/nodered-provisions"
},
"args": [
"run",
"/app/modules/nodered/dist/mqtt/index.js"
],
"restart-on": [
"mqtt-env",
"bound-mqtt-topic",
"secret-mqtt-topic",
"settings"
]
],
"artifact": "runtime"
}
],
"requires": [
@@ -150,22 +173,23 @@
"mqtt-topic": "${dir:state}/mqtt-topic.secret"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js",
"mqtt/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
# openai-consumer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/openai-consumer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/openai-consumer/dist /app/modules/openai-consumer/dist
# No serve-time entrypoints: every container of this module names its command (`run` on a
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.

Some files were not shown because too many files have changed in this diff Show More