mongodb: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)

The mesh-mongodb container goes with its Dockerfile, build bases and bus credential. Its client shelled out to mongosh, which no machine's system carries, so it now speaks to the server through the official mongodb driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4); the tools answer exactly as before (relaxed Extended JSON). The server is reached on loopback at the port the machine published (${port:27017}). The root secret was owned by the mongo image's user (secrets-owner 999:999), which the runtime's account cannot read; the module's own copy is now the runtime's, and the server is given its own 999-owned copy rendered from the same secret.
This commit is contained in:
jochen
2026-10-04 01:17:41 +02:00
parent ed50130a6a
commit da8a46cfe8
7 changed files with 106 additions and 167 deletions
-37
View File
@@ -1,37 +0,0 @@
# 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
+69 -78
View File
@@ -1,19 +1,16 @@
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this // 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. // module's tools and its provisioner import it, and nothing outside mongodb does.
// //
// Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface // `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code
// than this should carry — so it shells out to the shell the mongodb image ships, the same way // now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the
// postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary, // server through the official `mongodb` driver its package.json names — installed and inlined into
// `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and // the bundle by the builder. One connection per call, as one mongosh invocation was: the module is
// its result comes back as EJSON on stdout. // called rarely, and a pool held open across calls would hold a credential the mesh may rotate.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface DatabaseInfo { export interface DatabaseInfo {
readonly name: string; readonly name: string;
@@ -59,32 +56,26 @@ export class MongoClient {
return this.conn.port; return this.conn.port;
} }
/** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ /** The admin connection URI, credentials percent-encoded. */
private uri(): string { private uri(): string {
const u = encodeURIComponent(this.conn.user); const u = encodeURIComponent(this.conn.user);
const p = encodeURIComponent(this.conn.password); const p = encodeURIComponent(this.conn.password);
const a = encodeURIComponent(this.conn.authSource); const a = encodeURIComponent(this.conn.authSource);
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`; return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`;
} }
/** /**
* Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see * The one execution boundary: connect as the administrator, do `work`, and close — a failure to
* header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method * connect or to authenticate rejects here rather than returning a partial success.
* 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.
*/ */
async evalJs<T>(js: string): Promise<T> { private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> {
const { stdout } = await run( const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 });
"mongosh", try {
[this.uri(), "--quiet", "--eval", js], await client.connect();
{ maxBuffer: 16 << 20 }, return await work(client);
); } finally {
const text = stdout.trim(); await client.close();
if (text.length === 0) {
throw new Error("mongosh returned no output — the eval printed nothing");
} }
return JSON.parse(text) as T;
} }
/** /**
@@ -94,19 +85,16 @@ export class MongoClient {
* password and roles, so a rotated credential converges. * password and roles, so a rotated credential converges.
*/ */
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> { async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
const js = ` await this.admin(async (client) => {
const target = db.getSiblingDB(${lit(database)}); const target = client.db(database);
let existing = null; const roles = [{ role: "dbOwner", db: database }];
try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; } const found = await target.command({ usersInfo: user });
const roles = [{ role: "dbOwner", db: ${lit(database)} }]; if (Array.isArray(found.users) && found.users.length > 0) {
if (existing) { await target.command({ updateUser: user, pwd: password, roles });
target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles });
} else { } else {
target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles }); await target.command({ createUser: user, pwd: password, roles });
} }
print(EJSON.stringify({ ok: 1 })); });
`;
await this.evalJs<{ ok: number }>(js);
} }
/** /**
@@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 }));
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/ */
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> { async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Connected without credentials, then authenticated inside the eval from the environment, so // Credentials as options, never in a URI, so the consumer's password is in no message a failed
// the consumer's password is neither on argv nor in the message of a failed command. // connection prints.
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, {
const js = auth: { username: user, password },
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + authSource: database,
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + serverSelectionTimeoutMS: 10_000,
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; });
let stdout: string;
try { try {
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { await client.connect();
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, const status = await client.db(database).command({ connectionStatus: 1 });
timeout: 30_000, const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[];
}));
} catch (err) {
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); return roles.some((r) => r.role === "dbOwner" && r.db === database);
} 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();
}
} }
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the /** 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. */ * user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {
const js = ` await this.admin(async (client) => {
const target = db.getSiblingDB(${lit(database)}); const target = client.db(database);
try { target.dropUser(${lit(user)}); } catch (e) {} try {
target.dropDatabase(); await target.command({ dropUser: user });
print(EJSON.stringify({ ok: 1 })); } catch (err) {
`; if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
await this.evalJs<{ ok: number }>(js); }
await target.dropDatabase();
});
} }
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
async listDatabases(): Promise<DatabaseInfo[]> { async listDatabases(): Promise<DatabaseInfo[]> {
const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( const res = await this.admin((client) => client.db("admin").admin().listDatabases());
`print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`,
);
return (res.databases ?? []) return (res.databases ?? [])
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
@@ -162,6 +148,8 @@ print(EJSON.stringify({ ok: 1 }));
/** /**
* Run a read-only `find` against a collection in a named database, for the mongodb_query tool. * 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. * `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( async find(
database: string, database: string,
@@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 }));
limit: number, limit: number,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000)); const capped = Math.max(1, Math.min(limit, 1000));
const js = const docs = await this.admin((client) =>
`print(EJSON.stringify(` + client
`db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` + .db(database)
`.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` + .collection(collection)
`));`; .find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document)
return this.evalJs<Record<string, unknown>[]>(js); .limit(capped)
.toArray(),
);
return BSON.EJSON.serialize(docs, { relaxed: true }) as Record<string, unknown>[];
} }
} }
/** 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. */ /** Generate a URL-safe password. */
export function generatePassword(): string { export function generatePassword(): string {
return randomBytes(24).toString("base64url"); 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 { function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined; if (!path) return undefined;
try { try {
+3 -3
View File
@@ -1,9 +1,9 @@
// mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs // mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where // (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mongodb.database.provisioned — a consumer's database + owning user was created // module.mongodb.database.provisioned — a consumer's database + owning user was created
// module.mongodb.database.deprovisioned — that database was removed // module.mongodb.database.deprovisioned — that database was removed
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a // Here in the runtime 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. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+28 -43
View File
@@ -39,17 +39,9 @@
"mongodb-database": "${dir:grants}" "mongodb-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret", "root": "${dir:state}/root.secret"
"broker": "${dir:mesh-state}/broker"
}, },
"secrets-owner": "999:999",
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -71,6 +63,14 @@
"type": "network", "type": "network",
"name": "mongodb" "name": "mongodb"
}, },
{
"id": "server-root",
"type": "file",
"path": "${dir:state}/server-root.secret",
"mode": "0400",
"owner": "999:999",
"content": "${secret:root}"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
@@ -86,46 +86,31 @@
], ],
"volumes": [ "volumes": [
"${dir:data}:/data/db", "${dir:data}:/data/db",
"${dir:state}/root.secret:/run/secrets/root:ro" "${dir:state}/server-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": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "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"
}
} }
] ]
} }
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1",
"mongodb": "^6.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+1 -2
View File
@@ -11,8 +11,7 @@
// same-named database under exactly that login — a name the consumer cannot learn is a database it // same-named database under exactly that login — a name the consumer cannot learn is a database it
// cannot reach. // cannot reach.
// //
// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see // The commands run through MongoClient, the official driver inside this bundle (see client.ts).
// client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; 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 // 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 through // return structured data; the mesh serves them through the sdk's tool harness. Both call the server
// MongoClient.evalJs(), the module's one execution boundary (see client.ts). // through MongoClient, the driver inside this bundle (see client.ts).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MongoClient } from "../client.js"; import { MongoClient } from "../client.js";