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
+144
View File
@@ -0,0 +1,144 @@
// The Lidarr API client — lidarr's own code, living in the module (novox/hq ADR 0044). Ported from
// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a
// change to Lidarr's API rebuilds only lidarr and nothing else. Both this module's tools and its
// events entrypoint import it, and nothing outside lidarr does.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Lidarr speaks the v1 API (Radarr/Sonarr are v3); its content is the "artist".
const API_VERSION = "v1";
const CONTENT_ENDPOINT = "artist";
const APP_NAME = "Lidarr";
export interface LidarrQueueItem {
/** The queue record id — stable while the item is in the queue, so events can diff on it. */
id: number;
title: string;
status: string;
size: string;
sizeleft: string;
timeleft?: string;
}
export interface LidarrCalendarItem {
title: string;
date: string;
overview?: string;
}
export interface LidarrContentItem {
title: string;
status?: string;
monitored: boolean;
}
export class LidarrClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. The URL defaults to the server on this node (the
* runtime shares its network), and the API key is read from MESH_LIDARR_API_KEY or, failing that,
* discovered from the server's own config.xml under MESH_LIDARR_CONFIG_DIR — the same file Lidarr
* writes it to, so a running server needs nothing configured by hand. Throws when no key can be
* found, so the tools/events simply do not load (the harness treats the throw as "exposes
* nothing").
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): LidarrClient {
const url = env.MESH_LIDARR_URL ?? `http://127.0.0.1:${env.MESH_LIDARR_PORT ?? "8686"}`;
const configDir = env.MESH_LIDARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_LIDARR_API_KEY ?? LidarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Lidarr not configured — set MESH_LIDARR_API_KEY or make the config dir readable");
}
return new LidarrClient(url, apiKey);
}
/** Discover the API key from the server's config.xml, falling back to null. Every Servarr app
* writes <ApiKey> into config.xml at the root of its config directory. */
static detectApiKey(configDir: string): string | null {
const config = join(configDir, "config.xml");
if (existsSync(config)) {
const match = readFileSync(config, "utf8").match(/<ApiKey>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } });
if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`);
return res.json();
}
async getStatus(): Promise<{ appName: string; version: string }> {
const data = (await this.get("system/status")) as { appName?: string; version?: string };
return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" };
}
async getContent(limit?: number): Promise<LidarrContentItem[]> {
const data = await this.get(CONTENT_ENDPOINT);
const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []);
const mapped = items.map((item) => ({
// Lidarr's content is an artist; its display name is artistName, not title.
title: item.artistName ?? item.title ?? "Unknown",
status: item.status,
monitored: item.monitored ?? true,
}));
return limit ? mapped.slice(0, limit) : mapped;
}
/** Library search is a filter over existing content, not an indexer lookup — same as hal's. */
async searchContent(term: string): Promise<LidarrContentItem[]> {
const all = await this.getContent();
const lower = term.toLowerCase();
return all.filter((item) => item.title.toLowerCase().includes(lower));
}
async getQueue(): Promise<{ totalRecords: number; items: LidarrQueueItem[] }> {
const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] };
const records = data.records ?? [];
return {
totalRecords: data.totalRecords ?? records.length,
items: records.map((r) => ({
id: r.id,
title: r.title ?? r.artist?.artistName ?? r.album?.title ?? "Unknown",
status: r.status ?? "unknown",
size: formatBytes(r.size ?? 0),
sizeleft: formatBytes(r.sizeleft ?? 0),
timeleft: r.timeleft,
})),
};
}
async getCalendar(days = 7): Promise<LidarrCalendarItem[]> {
const start = new Date().toISOString().split("T")[0];
const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0];
const data = await this.get("calendar", { start, end });
const items: any[] = Array.isArray(data) ? data : [];
return items.map((item) => ({
// A Lidarr calendar entry is an album release.
title: item.title ?? item.artist?.artistName ?? "Unknown",
date: item.releaseDate ?? "",
overview: item.overview?.slice(0, 150),
}));
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
+55
View File
@@ -0,0 +1,55 @@
// lidarr's events. The tool runtime imports this once the broker is bound. It watches the download
// queue and turns its comings and goings into mesh events.
//
// Emits (novox/hq ADR 0046/0047):
// module.lidarr.album.grabbed — a release entered the queue (Lidarr grabbed it)
// module.lidarr.download.completed — a release left the queue, imported. The download.completed
// routing key matches what a media consumer subscribes to
// (module.*.download.completed) to rescan its library.
// Consumes: none.
//
// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a
// restart mid-download does not re-announce everything already in flight as freshly grabbed.
import { emit } from "@novox/mesh-sdk/events";
import { LidarrClient, type LidarrQueueItem } from "./client.js";
const lidarr = LidarrClient.fromEnv();
// Lidarr removes an item from the queue once it has been imported; a "warning"/"failed" status is
// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish.
const FAILED_STATUSES = new Set(["failed", "warning"]);
const inQueue = new Map<number, LidarrQueueItem>();
let primed = false;
async function pollQueue(): Promise<void> {
const { items } = await lidarr.getQueue();
const now = new Map(items.map((i) => [i.id, i]));
if (primed) {
// Entered the queue since last look — Lidarr grabbed a release.
for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("module.lidarr.album.grabbed", { title: item.title, status: item.status });
}
// Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("module.lidarr.download.completed", { title: item.title });
}
}
}
inQueue.clear();
for (const [id, item] of now) inQueue.set(id, item);
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[lidarr] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollQueue, 30_000);
console.log("[lidarr] watching the download queue, emitting grabs and completions");
+87
View File
@@ -0,0 +1,87 @@
{
"module": "lidarr",
"version": "1",
"capabilities": [
"container-runtime"
],
"emits": [
"module.lidarr.album.grabbed",
"module.lidarr.download.completed"
],
"consumes": [],
"own-secrets": {
"broker": "/var/lib/mesh/lidarr/broker"
},
"listens": [
{
"port": 8686,
"protocol": "tcp",
"from": "mesh",
"why": "managing music"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/lidarr",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
"path": "/services/lidarr/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "media-music",
"type": "directory",
"path": "/services/media/music",
"mode": "0755",
"owner": "1000:1000"
},
{
"id": "media-downloads",
"type": "directory",
"path": "/services/media/downloads",
"mode": "0755",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "lidarr",
"image": "lscr.io/linuxserver/lidarr@sha256:6b38dd330b0c653351c2e23c8b962ea51c95683dd7acace9d106c922baf85f75",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"8686"
],
"volumes": [
"/services/lidarr/config:/config",
"/services/media/music:/music",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-lidarr",
"image": "mesh-runtime-lidarr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/lidarr/broker:/run/secrets/broker:ro",
"/services/lidarr/config:/var/lib/lidarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:8686",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-lidarr",
"version": "0.1.0",
"description": "lidarr — music management. Its API client, 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"
}
}
+78
View File
@@ -0,0 +1,78 @@
// lidarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing lidarr's own
// client. They return structured data (not pre-formatted text as hal did); the mesh serves them
// through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { LidarrClient } from "../client.js";
export function getLidarrTools(lidarr: LidarrClient): ToolDefinition[] {
return [
{
name: "lidarr_status",
description: "Lidarr status overview: version, artist count, monitored count, queue size.",
input: {},
run: async () => {
const [status, content, queue] = await Promise.all([
lidarr.getStatus(),
lidarr.getContent(),
lidarr.getQueue(),
]);
return {
app: status.appName,
version: status.version,
artists: content.length,
monitored: content.filter((c) => c.monitored).length,
queue: queue.totalRecords,
};
},
},
{
name: "lidarr_library",
description: "List artists from the Lidarr library.",
input: { limit: { type: "number", description: "max items to return (default 50)" } },
run: async (args) => {
const items = await lidarr.getContent(args.limit ? Number(args.limit) : 50);
return { count: items.length, artists: items };
},
},
{
name: "lidarr_search",
description: "Search the Lidarr library for artists by name (filters existing content, not indexers).",
input: { query: { type: "string", description: "the search term" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await lidarr.searchContent(query) };
},
},
{
name: "lidarr_queue",
description: "Show the Lidarr download queue — what is downloading and how far along.",
input: {},
run: async () => {
const queue = await lidarr.getQueue();
return { count: queue.totalRecords, items: queue.items };
},
},
{
name: "lidarr_calendar",
description: "Upcoming album releases from the Lidarr calendar.",
input: { days: { type: "number", description: "how many days to look ahead (default 7)" } },
run: async (args) => {
const days = args.days ? Number(args.days) : 7;
const items = await lidarr.getCalendar(days);
items.sort((a, b) => a.date.localeCompare(b.date));
return { days, count: items.length, items };
},
},
];
}
// The tools exist only when Lidarr is configured; without a URL and key, lidarr contributes none
// rather than failing the whole runtime.
registerModuleTools("lidarr", (env) => {
try {
return getLidarrTools(LidarrClient.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", "tools/index.ts"]
}