Mirrors the proven catalog patterns field-for-field: - lidarr -> the Servarr twin of radarr/sonarr (API v1, artist content); no provisioner (it is a consumer app). - mongodb -> postgres shape: mongodb-database provider, provisioner mints a per-consumer db+user (ADR 0053), client shells to mongosh (no npm driver, the psql convention). - mssql -> postgres shape: mssql-database provider, sqlcmd client. - mosquitto -> redis shape: mqtt-topic provider via the Dynamic Security plugin, deliberately avoiding hal's password_file (that file is nox issue 011 exactly); provisioner mints a per-consumer MQTT client+role. All four typecheck (strict, NodeNext) against the built @novox/mesh-sdk, and their service images are digest-pinned to resolved registry digests. The mesh-runtime-<mod> images keep the all-zeros placeholder the pipeline pins, as postgres/redis do, and must bundle each module's CLI (mongosh/sqlcmd/ mosquitto_ctrl) as mesh-runtime-postgres bundles psql. Not yet lab-verified: each module lists in-code what an integration test must prove (auth model, provisioner reconcile, mosquitto dynsec bootstrap ordering). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
181 lines
7.0 KiB
TypeScript
181 lines
7.0 KiB
TypeScript
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0044). 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.
|
|
|
|
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);
|
|
|
|
export interface DatabaseInfo {
|
|
readonly name: string;
|
|
readonly sizeBytes: number;
|
|
}
|
|
|
|
export interface MongoConn {
|
|
readonly host: string;
|
|
readonly port: number;
|
|
readonly user: string;
|
|
readonly password: string;
|
|
/** The database the admin user authenticates against — `admin` for the root user. */
|
|
readonly authSource: string;
|
|
}
|
|
|
|
export class MongoClient {
|
|
constructor(private readonly conn: MongoConn) {}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. Reads MESH_MONGODB_* first (the documented names),
|
|
* falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner container.
|
|
* Throws if it cannot find a host and an admin password.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): MongoClient {
|
|
const url = env.MESH_PROVISION_MONGODB ? safeUrl(env.MESH_PROVISION_MONGODB) : undefined;
|
|
const host = env.MESH_MONGODB_HOST ?? url?.hostname;
|
|
const port = Number(env.MESH_MONGODB_PORT ?? url?.port ?? "27017") || 27017;
|
|
const user = env.MESH_MONGODB_USER ?? (url?.username ? decodeURIComponent(url.username) : "root");
|
|
const authSource =
|
|
env.MESH_MONGODB_AUTHSOURCE ?? url?.searchParams.get("authSource") ?? "admin";
|
|
const password = env.MESH_MONGODB_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
|
|
if (!host || !password) {
|
|
throw new Error("mongodb host or admin password is not set — mongodb's own code cannot reach the server");
|
|
}
|
|
return new MongoClient({ host, port, user, password, authSource });
|
|
}
|
|
|
|
get host(): string {
|
|
return this.conn.host;
|
|
}
|
|
|
|
get port(): number {
|
|
return this.conn.port;
|
|
}
|
|
|
|
/** 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}`;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Create a login user and the database it owns, idempotently. The user is created inside the
|
|
* target database with the `dbOwner` role scoped to that database, so the consumer owns exactly
|
|
* its own and authenticates with the target database as its authSource. Re-running updates the
|
|
* password and roles, so a rotated credential converges.
|
|
*/
|
|
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
|
|
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);
|
|
}
|
|
|
|
/** 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> {
|
|
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.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));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async find(
|
|
database: string,
|
|
collection: string,
|
|
filter: Readonly<Record<string, unknown>>,
|
|
limit: number,
|
|
): Promise<Record<string, unknown>[]> {
|
|
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<Record<string, unknown>[]>(js);
|
|
}
|
|
}
|
|
|
|
/** 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 {
|
|
return readFileSync(path, "utf8").trim();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function safeUrl(raw: string): URL | undefined {
|
|
try {
|
|
return new URL(raw);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|