Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)

Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-04 23:08:40 +02:00
parent 7b55e834e9
commit 9e156a5b9e
38 changed files with 668 additions and 51 deletions
+13 -3
View File
@@ -7,6 +7,8 @@
// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is
// captured on login and carried by hand on every later call, with a single re-login on expiry.
import { readFileSync } from "node:fs";
export interface QbTransferInfo {
dlSpeedBytesPerSec: number;
upSpeedBytesPerSec: number;
@@ -30,6 +32,13 @@ export interface QbTorrent {
savePath: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class QbittorrentClient {
readonly baseUrl: string;
private sid: string | null = null;
@@ -49,12 +58,13 @@ export class QbittorrentClient {
* (the harness treats the throw as "exposes nothing"). The user defaults to "admin".
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient {
const url = env.MESH_QBITTORRENT_URL;
const password = env.MESH_QBITTORRENT_PASSWORD;
const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_QBITTORRENT_URL;
const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD;
if (!url || !password) {
throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD");
}
const user = env.MESH_QBITTORRENT_USER ?? "admin";
const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin";
return new QbittorrentClient(url, user, password);
}
+18
View File
@@ -58,6 +58,24 @@
"/services/qbittorrent/config:/config",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-qbittorrent",
"image": "mesh-runtime-qbittorrent@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro",
"/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro",
"/services/qbittorrent/config:/var/lib/qbittorrent/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
}
}
]
}