Convert four hal modules: lidarr, mongodb, mssql, mosquitto

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
This commit is contained in:
2026-09-05 04:17:07 +02:00
parent bca68c2109
commit c82b3ff706
27 changed files with 1793 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
// 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;
}
}
+25
View File
@@ -0,0 +1,25 @@
// 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 0046/0047):
// 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
// database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events";
interface DatabaseEvent {
consumer: string;
database: string;
user?: string;
}
await on<DatabaseEvent>("module.mongodb.database.provisioned", async (e) => {
console.log(`[mongodb] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
});
await on<DatabaseEvent>("module.mongodb.database.deprovisioned", async (e) => {
console.log(`[mongodb] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
});
console.log("[mongodb] auditing database lifecycle events");
+117
View File
@@ -0,0 +1,117 @@
{
"module": "mongodb",
"version": "1",
"provides": [
{
"name": "mongodb-database",
"scope": "mesh"
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"module.mongodb.database.provisioned",
"module.mongodb.database.deprovisioned"
],
"consumes": [
"module.mongodb.database.provisioned",
"module.mongodb.database.deprovisioned"
],
"listens": [
{
"port": 27017,
"protocol": "tcp",
"from": "mesh",
"why": "modules on any machine that were granted a database"
}
],
"serves": {
"mongodb-database": {}
},
"receives": {
"mongodb-database": "/var/lib/mongodb/grants/mesh.json"
},
"grants": {
"mongodb-database": "/var/lib/mongodb/grants"
},
"own-secrets": {
"root": "/var/lib/mongodb/root.secret",
"broker": "/var/lib/mesh/mongodb/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/mongodb",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/mongodb",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/mongodb/grants",
"mode": "0700"
},
{
"id": "root-env",
"type": "file",
"path": "/var/lib/mongodb/root.env",
"mode": "0600",
"content": "MONGO_INITDB_ROOT_PASSWORD=${secret:root}\n"
},
{
"id": "data",
"type": "directory",
"path": "/services/mongodb/db-data",
"mode": "0700"
},
{
"id": "net",
"type": "network",
"name": "mongodb"
},
{
"id": "server",
"type": "container",
"name": "mongo",
"image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3",
"network": "mongodb",
"env": {
"MONGO_INITDB_ROOT_USERNAME": "root"
},
"env-file": [
"/var/lib/mongodb/root.env"
],
"ports": [
"27017"
],
"volumes": [
"/services/mongodb/db-data:/data/db"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mongodb",
"image": "mesh-runtime-mongodb@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "mongodb",
"volumes": [
"/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro",
"/var/lib/mongodb/grants:/var/lib/mongodb/grants:ro",
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@mongo:27017/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json"
}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-mongodb",
"version": "0.1.0",
"description": "mongodb — provides the mesh mongodb-database interface. Its client, provisioner, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
// mongodb's provisioner — the adapter that makes mongodb a provider of the mesh `mongodb-database`
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
// the sdk harness's; this writes only the per-service half: how mongodb creates and removes a
// consumer's database + owning user (novox/hq ADR 0044/0045/0053).
//
// The `mongodb-database` interface: a consumer connects to a database it alone owns, as `as` with the
// password the mesh minted, authenticating against that same database.
//
// **The user name and password are the mesh's, not the provisioner's (ADR 0053).** The mesh derives
// the login and hands it to both ends, and mints the password. mongodb creates a user and a
// 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).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { MongoClient } from "../client.js";
const mongo = MongoClient.fromEnv();
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:mongodb-database] emit ${type} failed: ${err}`);
}
}
runProvisioner("mongodb-database", {
async create(p: Provision): Promise<void> {
// Database and owning user share the consumer's login, so the consumer owns exactly its own.
const database = p.as;
await mongo.createDatabaseAndUser(database, p.as, p.password);
await announce("module.mongodb.database.provisioned", {
consumer: p.consumer ?? "",
database,
user: p.as,
});
},
async remove(p: { as: string }): Promise<void> {
await mongo.dropDatabaseAndUser(p.as, p.as);
await announce("module.mongodb.database.deprovisioned", { database: p.as });
},
});
+51
View File
@@ -0,0 +1,51 @@
// mongodb's tools — mongodb's own code (novox/hq ADR 0044), 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).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MongoClient } from "../client.js";
export function getMongoTools(mongo: MongoClient): ToolDefinition[] {
return [
{
name: "mongodb_list_databases",
description: "List the databases on the mongodb server, with their on-disk size.",
input: {},
run: async () => ({ databases: await mongo.listDatabases() }),
},
{
name: "mongodb_query",
description: "Run a read-only find against a collection in a named database and return the matching documents.",
input: {
database: { type: "string", description: "the database to query" },
collection: { type: "string", description: "the collection to read from" },
filter: { type: "object", description: "the MongoDB query filter (defaults to {} — all documents)" },
limit: { type: "number", description: "maximum documents to return (default 100, capped at 1000)" },
},
run: async (args) => {
const database = String(args.database ?? "");
const collection = String(args.collection ?? "");
if (!database) throw new Error("mongodb_query: database is required");
if (!collection) throw new Error("mongodb_query: collection is required");
const filter = isObject(args.filter) ? args.filter : {};
const limit = Number(args.limit ?? 100) || 100;
const documents = await mongo.find(database, collection, filter, limit);
return { database, collection, documents };
},
},
];
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
// The tools exist only when the server can be reached from the environment; without it, mongodb
// contributes none rather than failing the whole tool runtime.
registerModuleTools("mongodb", (env) => {
try {
return getMongoTools(MongoClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}