diff --git a/modules/mongodb/Dockerfile b/modules/mongodb/Dockerfile deleted file mode 100644 index 05f3aef..0000000 --- a/modules/mongodb/Dockerfile +++ /dev/null @@ -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 diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index ae8c666..946de94 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -1,19 +1,16 @@ // 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. // -// 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. +// **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. import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const run = promisify(execFile); +import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb"; export interface DatabaseInfo { readonly name: string; @@ -59,32 +56,26 @@ export class MongoClient { return this.conn.port; } - /** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ + /** The admin connection URI, 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}`; + 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 - * 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. + * 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. */ - async evalJs(js: string): Promise { - 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"); + private async admin(work: (client: Driver) => Promise): Promise { + const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 }); + try { + await client.connect(); + return await work(client); + } finally { + await client.close(); } - return JSON.parse(text) as T; } /** @@ -94,19 +85,16 @@ export class MongoClient { * password and roles, so a rotated credential converges. */ async createDatabaseAndUser(database: string, user: string, password: string): Promise { - 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); + 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 }); + } + }); } /** @@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 })); * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). */ async canAuthenticateAs(database: string, user: string, password: string): Promise { - // 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; + // 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, + }); try { - ({ 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, - })); + 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); } 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]}`); + 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 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 { - 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); + 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(); + }); } /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ async listDatabases(): Promise { - const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( - `print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`, - ); + const res = await this.admin((client) => client.db("admin").admin().listDatabases()); return (res.databases ?? []) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .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. * `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, @@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 })); limit: number, ): Promise[]> { const capped = Math.max(1, Math.min(limit, 1000)); - const js = - `print(EJSON.stringify(` + - `db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` + - `.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` + - `));`; - return this.evalJs[]>(js); + 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[]; } } +/** 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 { diff --git a/modules/mongodb/index.ts b/modules/mongodb/index.ts index ebca8d5..24dd511 100644 --- a/modules/mongodb/index.ts +++ b/modules/mongodb/index.ts @@ -1,9 +1,9 @@ -// 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 +// 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 // 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 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. import { on } from "@novox/mesh-sdk/events"; diff --git a/modules/mongodb/module.json b/modules/mongodb/module.json index 8aa8a40..1f70756 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -39,17 +39,9 @@ "mongodb-database": "${dir:grants}" }, "own-secrets": { - "root": "${dir:state}/root.secret", - "broker": "${dir:mesh-state}/broker" + "root": "${dir:state}/root.secret" }, - "secrets-owner": "999:999", "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -71,6 +63,14 @@ "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,46 +86,31 @@ ], "volumes": [ "${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": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], "artifacts": [ { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" + "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" + } } ] } diff --git a/modules/mongodb/package.json b/modules/mongodb/package.json index 479e7ea..be35a99 100644 --- a/modules/mongodb/package.json +++ b/modules/mongodb/package.json @@ -5,7 +5,8 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-sdk": "^0.1.1", + "mongodb": "^6.21.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index aaa279d..3b905a1 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -11,8 +11,7 @@ // same-named database under exactly that login — a name the consumer cannot learn is a database it // cannot reach. // -// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see -// client.ts). +// The commands run through MongoClient, the official driver inside this bundle (see client.ts). import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; diff --git a/modules/mongodb/tools/index.ts b/modules/mongodb/tools/index.ts index b45b804..e200037 100644 --- a/modules/mongodb/tools/index.ts +++ b/modules/mongodb/tools/index.ts @@ -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 through -// MongoClient.evalJs(), the module's one execution boundary (see client.ts). +// 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). import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { MongoClient } from "../client.js";