sonarr, radarr: tool runtime container + self-detect API key from config.xml (ADR 0052)

Mirrors plex: a broker-bound runtime that serves the module's tools, discovering
the app's API key from its own config.xml under a read-only config-dir mount, URL
defaulting to the server on the node. Proven in the mesh-lab: assigned-sonarr green
(key detected, tools served under the scoped account, no live Sonarr needed).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-04 22:54:53 +02:00
parent 6615b5e3b3
commit 7b55e834e9
4 changed files with 81 additions and 15 deletions
+24 -7
View File
@@ -3,6 +3,9 @@
// change to Radarr's API rebuilds only radarr and nothing else. Both this module's tools and its
// events entrypoint import it, and nothing outside radarr does.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Radarr speaks the v3 API; its content is "movie".
const API_VERSION = "v3";
const CONTENT_ENDPOINT = "movie";
@@ -42,20 +45,34 @@ export class RadarrClient {
}
/**
* Build from the module's resolved environment. URL and key are read from MESH_RADARR_URL and
* MESH_RADARR_API_KEY; both must be present — an unconfigured Radarr throws rather than pretend to
* be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes
* 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_RADARR_API_KEY or, failing that,
* discovered from the server's own config.xml under MESH_RADARR_CONFIG_DIR — the same file Radarr
* 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): RadarrClient {
const url = env.MESH_RADARR_URL;
const apiKey = env.MESH_RADARR_API_KEY;
if (!url || !apiKey) {
throw new Error("Radarr not configured — set MESH_RADARR_URL and MESH_RADARR_API_KEY");
const url = env.MESH_RADARR_URL ?? `http://127.0.0.1:${env.MESH_RADARR_PORT ?? "7878"}`;
const configDir = env.MESH_RADARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable");
}
return new RadarrClient(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) {
+16
View File
@@ -66,6 +66,22 @@
"/services/media/movies:/movies",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-radarr",
"image": "mesh-runtime-radarr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/radarr/broker:/run/secrets/broker:ro",
"/services/radarr/config:/var/lib/radarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:7878",
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
}
}
]
}
+25 -8
View File
@@ -3,6 +3,9 @@
// change to Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its
// events entrypoint import it, and nothing outside sonarr does.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Sonarr speaks the v3 API; its content is "series".
const API_VERSION = "v3";
const CONTENT_ENDPOINT = "series";
@@ -42,20 +45,34 @@ export class SonarrClient {
}
/**
* Build from the module's resolved environment. URL and key are read from MESH_SONARR_URL and
* MESH_SONARR_API_KEY; both must be present — an unconfigured Sonarr throws rather than pretend to
* be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes
* nothing").
* 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_SONARR_API_KEY or, failing
* that, discovered from the server's own config.xml under MESH_SONARR_CONFIG_DIR — the same file
* Sonarr writes it to, so a running server needs nothing configured by hand (as plex does with
* its token). 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): SonarrClient {
const url = env.MESH_SONARR_URL;
const apiKey = env.MESH_SONARR_API_KEY;
if (!url || !apiKey) {
throw new Error("Sonarr not configured — set MESH_SONARR_URL and MESH_SONARR_API_KEY");
const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`;
const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable");
}
return new SonarrClient(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) {
+16
View File
@@ -74,6 +74,22 @@
"/services/media/anime:/anime",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-sonarr",
"image": "mesh-runtime-sonarr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro",
"/services/sonarr/config:/var/lib/sonarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:8989",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
}
}
]
}