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
+242
View File
@@ -0,0 +1,242 @@
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0044). Both this
// module's tools and its provisioner import it, and nothing outside mssql does.
//
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
// protocol is more surface than this should carry — so it shells out to the client the mssql
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
// through doveadm. One boundary, `run()`, and every method is built on it.
//
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
// column-aligned text, since SQL Server owns the quoting and typing.
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 QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
readonly command: string;
readonly rows: Record<string, unknown>[];
}
export interface MssqlConn {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
}
export class MssqlClient {
constructor(private readonly conn: MssqlConn) {}
/**
* Build from the module's resolved environment. Reads MESH_MSSQL_* 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): MssqlClient {
const url = env.MESH_PROVISION_MSSQL ? safeUrl(env.MESH_PROVISION_MSSQL) : undefined;
const host = env.MESH_MSSQL_HOST ?? url?.hostname;
const port = Number(env.MESH_MSSQL_PORT ?? url?.port ?? "1433") || 1433;
const user = env.MESH_MSSQL_USER ?? url?.username ?? "sa";
const password = env.MESH_MSSQL_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
if (!host || !password) {
throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server");
}
return new MssqlClient({ host, port, user, password });
}
get host(): string {
return this.conn.host;
}
get port(): number {
return this.conn.port;
}
/**
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
* failed statement an error here rather than a success with a warning, and `-C` trusts the
* server's self-signed certificate the mssql image ships with.
*/
async exec(sql: string, database = "master"): Promise<void> {
await this.sqlcmd(sql, database);
}
/**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
* prints (split across output lines for a large result, and reassembled here) is parsed. An
* empty result yields no output at all — an empty array.
*/
async query(select: string, database = "master"): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database);
return parseJsonRows(stdout);
}
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
private async sqlcmd(sql: string, database: string): Promise<string> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
// with postgres's — the module owns its own code (ADR 0044) and shells out to it.
const { stdout } = await run(
"sqlcmd",
[
"-S", `${this.conn.host},${this.conn.port}`,
"-U", this.conn.user,
"-d", database,
"-C",
"-b",
"-h", "-1",
"-y", "0",
"-Y", "0",
"-W",
"-Q", sql,
],
{ env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
);
return stdout;
}
/**
* Create a login and a database it owns (mapped as a db_owner user), idempotently. The login,
* the database and the user all carry the consumer's minted name, so the consumer owns exactly
* its own database — a name it cannot learn is a database it cannot reach (ADR 0053).
*/
async createDatabaseAndLogin(database: string, login: string, password: string): Promise<void> {
const logins = await this.query(
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
);
if (logins.length === 0) {
await this.exec(
`CREATE LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`,
);
} else {
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
}
const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
);
if (dbs.length === 0) {
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
await this.exec(`CREATE DATABASE ${ident(database)}`);
}
// Map the login to a db_owner user inside the database it owns.
const users = await this.query(
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)}`,
database,
);
if (users.length === 0) {
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
}
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
}
/** Drop a database and its login, idempotently, after evicting live connections. */
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
);
if (dbs.length > 0) {
// SINGLE_USER WITH ROLLBACK IMMEDIATE evicts every other session before the drop.
await this.exec(`ALTER DATABASE ${ident(database)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE`);
await this.exec(`DROP DATABASE ${ident(database)}`);
}
const logins = await this.query(
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
);
if (logins.length > 0) {
await this.exec(`DROP LOGIN ${ident(login)}`);
}
}
/** List the user databases (database_id > 4 excludes the system four), with size, for the tool. */
async listDatabases(): Promise<{ name: string; sizeBytes: number; state: string }[]> {
const rows = await this.query(
"SELECT d.name AS name, d.state_desc AS state, " +
"SUM(CAST(f.size AS bigint)) * 8 * 1024 AS size_bytes " +
"FROM sys.databases d JOIN sys.master_files f ON d.database_id = f.database_id " +
"WHERE d.database_id > 4 GROUP BY d.name, d.state_desc ORDER BY d.name",
);
return rows.map((r) => ({
name: String(r.name),
sizeBytes: Number(r.size_bytes ?? 0),
state: String(r.state ?? ""),
}));
}
/** Run a read-only SELECT against a named database, for the mssql_query tool. */
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
// The read-only guarantee is a wrapping transaction that is always rolled back: any write the
// statement attempts is undone. The rows are rendered by FOR JSON inside query().
const rows = await this.query(
`BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`,
database,
);
return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows };
}
}
/** Generate a URL-safe password. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
/** Quote a T-SQL identifier (square brackets, doubled internal `]`). */
export function ident(id: string): string {
return "[" + id.replace(/]/g, "]]") + "]";
}
/** Quote a T-SQL string literal (single quotes, doubled internal quotes). */
export function literal(val: string): string {
return "'" + val.replace(/'/g, "''") + "'";
}
/** Strip trailing semicolons and whitespace so FOR JSON can be appended to a caller's SELECT. */
function stripTrailingSemis(sql: string): string {
return sql.replace(/[\s;]+$/, "");
}
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;
}
}
/**
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
* a pure DDL batch) means no rows.
*/
function parseJsonRows(stdout: string): Record<string, unknown>[] {
const joined = stdout
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
}
+25
View File
@@ -0,0 +1,25 @@
// mssql'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.mssql.database.provisioned — a consumer's database + login/user was created
// module.mssql.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.mssql.database.provisioned", async (e) => {
console.log(`[mssql] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
});
await on<DatabaseEvent>("module.mssql.database.deprovisioned", async (e) => {
console.log(`[mssql] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
});
console.log("[mssql] auditing database lifecycle events");
+115
View File
@@ -0,0 +1,115 @@
{
"module": "mssql",
"version": "1",
"provides": [
{
"name": "mssql-database",
"scope": "mesh"
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"module.mssql.database.provisioned",
"module.mssql.database.deprovisioned"
],
"consumes": [
"module.mssql.database.provisioned",
"module.mssql.database.deprovisioned"
],
"listens": [
{
"port": 1433,
"protocol": "tcp",
"from": "mesh",
"why": "modules on any machine that were granted a database"
}
],
"serves": {
"mssql-database": {}
},
"receives": {
"mssql-database": "/var/lib/mssql/grants/mesh.json"
},
"grants": {
"mssql-database": "/var/lib/mssql/grants"
},
"own-secrets": {
"sa": "/var/lib/mssql/sa.secret",
"broker": "/var/lib/mesh/mssql/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/mssql",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/mssql",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/mssql/grants",
"mode": "0700"
},
{
"id": "sa-env",
"type": "file",
"path": "/var/lib/mssql/sa.env",
"mode": "0600",
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
},
{
"id": "data",
"type": "directory",
"path": "/services/mssql/db-data",
"mode": "0700",
"owner": "10001:0"
},
{
"id": "net",
"type": "network",
"name": "mssql"
},
{
"id": "server",
"type": "container",
"name": "mssql",
"image": "mcr.microsoft.com/mssql/server@sha256:ba4c8329f48fb8f02e1416be6a930ebfd71268caee78aa985f3af4315e457c89",
"network": "mssql",
"env-file": [
"/var/lib/mssql/sa.env"
],
"ports": [
"1433"
],
"volumes": [
"/services/mssql/db-data:/var/opt/mssql"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mssql",
"image": "mesh-runtime-mssql@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "mssql",
"volumes": [
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro",
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json"
}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-mssql",
"version": "0.1.0",
"description": "mssql — provides the mesh mssql-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"
}
}
+47
View File
@@ -0,0 +1,47 @@
// mssql's provisioner — the adapter that makes mssql a provider of the mesh `mssql-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 mssql creates and removes a
// consumer's database + login/user with the mesh-minted credential (novox/hq ADR 0044/0045/0053).
//
// The `mssql-database` interface: a consumer connects to a database it alone owns, as `as` with
// the password the mesh minted.
//
// **The login 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. mssql creates a login, a
// same-named database, and a db_owner user under exactly that login — a name the consumer cannot
// learn is a database it cannot reach.
//
// The DDL runs through MssqlClient, which is the module's one pending boundary (see client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { MssqlClient } from "../client.js";
const mssql = MssqlClient.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:mssql-database] emit ${type} failed: ${err}`);
}
}
runProvisioner("mssql-database", {
async create(p: Provision): Promise<void> {
// Database, login and user share the consumer's name, so the consumer owns exactly its own.
const database = p.as;
await mssql.createDatabaseAndLogin(database, p.as, p.password);
await announce("module.mssql.database.provisioned", {
consumer: p.consumer ?? "",
database,
user: p.as,
});
},
async remove(p: { as: string }): Promise<void> {
await mssql.dropDatabaseAndLogin(p.as, p.as);
await announce("module.mssql.database.deprovisioned", { database: p.as });
},
});
+44
View File
@@ -0,0 +1,44 @@
// mssql's tools — mssql's own code (novox/hq ADR 0044), importing mssql's own client. They return
// structured data; the mesh serves them through the sdk's tool harness. Both call through
// MssqlClient, the module's one pending execution boundary (see client.ts): the tool shapes are
// fixed and correct, and surface the work honestly through that boundary.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MssqlClient } from "../client.js";
export function getMssqlTools(mssql: MssqlClient): ToolDefinition[] {
return [
{
name: "mssql_list_databases",
description: "List the user databases on the mssql server, with their on-disk size and state.",
input: {},
run: async () => ({ databases: await mssql.listDatabases() }),
},
{
name: "mssql_query",
description: "Run a read-only SELECT against a named database (wrapped in a rolled-back transaction).",
input: {
database: { type: "string", description: "the database to query" },
sql: { type: "string", description: "a single SELECT statement" },
},
run: async (args) => {
const database = String(args.database ?? "");
const sql = String(args.sql ?? "");
if (!database) throw new Error("mssql_query: database is required");
if (!sql) throw new Error("mssql_query: sql is required");
const result = await mssql.readOnlyQuery(database, sql);
return { database, command: result.command, rows: result.rows };
},
},
];
}
// The tools exist only when the server can be reached from the environment; without it, mssql
// contributes none rather than failing the whole tool runtime.
registerModuleTools("mssql", (env) => {
try {
return getMssqlTools(MssqlClient.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"]
}