From 7b55e834e912dd9130229441608ca5a3311083f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 22:54:53 +0200 Subject: [PATCH] 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 --- modules/radarr/client.ts | 31 ++++++++++++++++++++++++------- modules/radarr/module.json | 16 ++++++++++++++++ modules/sonarr/client.ts | 33 +++++++++++++++++++++++++-------- modules/sonarr/module.json | 16 ++++++++++++++++ 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts index 74a4bff..6a659eb 100644 --- a/modules/radarr/client.ts +++ b/modules/radarr/client.ts @@ -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 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>/); + if (match) return match[1]; + } + return null; + } + private async get(endpoint: string, params?: Record): Promise { const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); if (params) { diff --git a/modules/radarr/module.json b/modules/radarr/module.json index a66fc63..607972f 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -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" + } } ] } diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts index d5f9c89..fbf7ec2 100644 --- a/modules/sonarr/client.ts +++ b/modules/sonarr/client.ts @@ -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 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>/); + if (match) return match[1]; + } + return null; + } + private async get(endpoint: string, params?: Record): Promise { const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`); if (params) { diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index 645da0b..9e19164 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -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" + } } ] }