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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user