// Where ombi reaches Sonarr, Radarr and Lidarr — decided by the mesh, written into ombi by ombi's // own API. // // **Why this exists.** ombi keeps its connection to each Servarr app in its own database // (OmbiSettings.db), not in a file, so the mesh has nowhere to write `${bound:sonarr-api:at}` for it. // ombi requires `sonarr-api`, `radarr-api` and `lidarr-api`; the mesh delivers, for each, a binding // (where the app is: `at`, and what it serves: `port`, `scheme`, `url-base`) and a pair credential // (the app's API key, accepted by the operator — a Servarr app has exactly one key and the mesh // cannot mint it). This step reads those files and makes ombi's settings say the same thing. // // **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. The // quality profile, root folder, language profile, tags, "enabled" and every other choice an operator // made in ombi's settings screen are left exactly as they are: the mesh knows where the app is, not // what ombi should do with it. Radarr's 4K instance is a different Radarr and is not touched. // // **A credential the app refuses is never written.** Until the operator accepts the app's API key // for this pair, the mesh delivers a value it minted itself, which no Servarr app will ever accept // (novox/hq ADR 0092). Writing it would replace a working key in ombi with a dead one. So the key is // tried against the app first; refused, nothing for that app is written and the step fails naming // the `secret accept` that fixes it. // // Pure logic and a small HTTP seam, so it is tested against fake servers (test/servarr.test.ts). import { readFile } from "node:fs/promises"; /** One Servarr app ombi connects to, and the shape of that connection in ombi's API. */ export interface ServarrApp { /** The app, as ombi's API names it: /Settings/, /Tester/. */ app: "sonarr" | "radarr" | "lidarr"; /** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */ provision: string; /** The app's own status endpoint, which answers 401 to a wrong key. */ statusPath: string; /** * Where the one connection sits in ombi's settings document. Radarr's is `{radarr, radarr4K}` * (two Radarr instances); only `radarr` is this provision's. */ within?: string; } export const APPS: readonly ServarrApp[] = [ { app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" }, { app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status", within: "radarr" }, { app: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" }, ]; /** The connection fields ombi keeps for an app — the only ones this step ever writes. */ export interface Connection { ip: string; port: number; ssl: boolean; /** ombi's name for the app's URL base; null when the app is served at the root. */ subDir: string | null; apiKey: string; } /** What the mesh wrote at `binds.`: the binding document (controller's boundFile). */ export interface Binding { provision?: string; from?: string; at?: string; as?: string; serves?: Record; } export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string }; /** * The connection the mesh says ombi should use, from the binding and the pair credential. * * Refused rather than guessed when the binding cannot be dialled from ombi's own container: a * loopback `at` — what the mesh hands a machine that is not on the private network — is ombi's * container itself, not the app. */ export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted { if (!binding) { return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` }; } const at = typeof binding.at === "string" ? binding.at.trim() : ""; const serves = binding.serves ?? {}; const port = Number(serves.port); if (!at) { return { ok: false, problem: `the ${spec.provision} binding names no host (at)` }; } if (isLoopback(at)) { return { ok: false, problem: `the ${spec.provision} binding says ${spec.app} is at ${at}, which from ombi's own container is ` + `ombi itself. The mesh hands loopback to a machine that is not on the private network; put it ` + `on the private network so ${spec.app} has an address ombi can dial`, }; } if (!Number.isInteger(port) || port <= 0 || port > 65535) { return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(serves.port)})` }; } const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http"; if (scheme !== "http" && scheme !== "https") { return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}, which ombi cannot dial` }; } const key = (credential ?? "").trim(); if (!key) { return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` }; } return { ok: true, from: typeof binding.from === "string" ? binding.from : "", connection: { ip: at, port, ssl: scheme === "https", subDir: subDirOf(serves["url-base"]), apiKey: key }, }; } /** ombi's `subDir`: the URL base with its slashes trimmed, null when there is none. */ export function subDirOf(urlBase: unknown): string | null { const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : ""; return trimmed === "" ? null : trimmed; } function isLoopback(host: string): boolean { const h = host.toLowerCase(); return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h); } /** Which connection fields differ between what ombi holds and what the mesh says. Names only. */ export function differing(current: Record | undefined, want: Connection): (keyof Connection)[] { const now = current ?? {}; const out: (keyof Connection)[] = []; if (String(now.ip ?? "") !== want.ip) out.push("ip"); if (Number(now.port ?? 0) !== want.port) out.push("port"); if (Boolean(now.ssl) !== want.ssl) out.push("ssl"); if (subDirOf(now.subDir) !== want.subDir) out.push("subDir"); if (String(now.apiKey ?? "") !== want.apiKey) out.push("apiKey"); return out; } /** ombi's settings for the app with the connection laid over them and nothing else changed. */ export function withConnection(current: Record | undefined, want: Connection): Record { return { ...(current ?? {}), ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, apiKey: want.apiKey }; } /** The app's base URL as the step dials it — the same host and port ombi will be given. */ export function appUrl(want: Connection): string { const scheme = want.ssl ? "https" : "http"; const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip; return `${scheme}://${host}:${want.port}${want.subDir ? `/${want.subDir}` : ""}`; } /** How one app came out. */ export type Outcome = | { app: string; result: "unchanged" } | { app: string; result: "written"; fields: string[] } | { app: string; result: "refused"; problem: string }; /** The HTTP the step needs, so a test can stand fakes in for ombi and the apps. */ export interface Http { fetch(url: string, init?: { method?: string; headers?: Record; body?: string }): Promise<{ status: number; text(): Promise; }>; } export interface Ombi { url: string; apiKey: string; } async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise { const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, { method, headers: { ApiKey: ombi.apiKey, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); if (res.status < 200 || res.status >= 300) { // The body is ombi's error, never a request echo, so it carries no key. throw new Error(`ombi ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`); } return text ? (JSON.parse(text) as unknown) : undefined; } /** * Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error * when it could not be asked — unreachable, or answering something that is neither. */ export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise { const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, { method: "GET", headers: { "X-Api-Key": want.apiKey, Accept: "application/json" }, }); if (res.status === 401 || res.status === 403) return false; if (res.status >= 200 && res.status < 300) return true; throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`); } /** The remedy for a refused key, in the controller's own words (ADR 0092). */ export function acceptRemedy(spec: ServarrApp, from: string): string { return ( `${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` + `into ombi. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` + `key for this pair — \`secret accept ombi ${spec.provision} --provider ${from || ""} ` + `--from \`` ); } /** * Bring ombi's connection to one app in line with the mesh: check the key against the app, compare, * write only the connection fields when they differ, then have ombi test the connection from its own * container. Never throws: every failure is an outcome with a reason. */ export async function reconcileApp( http: Http, ombi: Ombi, spec: ServarrApp, binding: Binding | undefined, credential: string | undefined, ): Promise { const w = wanted(spec, binding, credential); // `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not // narrow. if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem }; const want = w.connection; try { if (!(await appTakes(http, spec, want))) { return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) }; } } catch (err) { return { app: spec.app, result: "refused", problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`, }; } try { const document = (await ombiCall(http, ombi, "GET", `/Settings/${spec.app}`)) as Record | undefined; const current = spec.within ? (document?.[spec.within] as Record | undefined) : document; const fields = differing(current, want); if (fields.length > 0) { const next = withConnection(current, want); const body = spec.within ? { ...(document ?? {}), [spec.within]: next } : next; const saved = await ombiCall(http, ombi, "POST", `/Settings/${spec.app}`, body); if (saved === false) { return { app: spec.app, result: "refused", problem: `ombi declined to save its ${spec.app} settings` }; } } // ombi's own test, from ombi's own container — the path the step's check above did not take. const tested = (await ombiCall(http, ombi, "POST", `/Tester/${spec.app}`, withConnection(current, want))) as | { isValid?: boolean; expectedSubDir?: string | null } | undefined; if (!tested?.isValid) { const hint = tested?.expectedSubDir ? ` (ombi expected the base path ${tested.expectedSubDir})` : ""; return { app: spec.app, result: "refused", problem: `ombi cannot reach ${spec.app} at ${want.ip}:${want.port} from its own container${hint}` + (fields.length > 0 ? `; its settings were written (${fields.join(", ")})` : ""), }; } return fields.length > 0 ? { app: spec.app, result: "written", fields } : { app: spec.app, result: "unchanged" }; } catch (err) { return { app: spec.app, result: "refused", problem: message(err) }; } } /** Wait for ombi to answer, because the step runs right after its container starts. */ export async function ombiReady(http: Http, ombi: Ombi, waitMs: number, pauseMs = 2000): Promise { const until = Date.now() + waitMs; for (;;) { try { const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1/Status`, { method: "GET" }); if (res.status === 200) return true; } catch { // not listening yet } if (Date.now() >= until) return false; await new Promise((r) => setTimeout(r, pauseMs)); } } /** A file the mesh wrote, or undefined when it is not there. */ export async function readIfThere(path: string | undefined): Promise { if (!path) return undefined; return readFile(path, "utf8").catch(() => undefined); } /** A binding file parsed, or undefined when absent or not JSON. */ export async function readBinding(path: string | undefined): Promise { const raw = await readIfThere(path); if (raw === undefined) return undefined; try { return JSON.parse(raw) as Binding; } catch { return undefined; } } function message(err: unknown): string { return err instanceof Error ? err.message : String(err); }