bazarr: reach sonarr and radarr through the mesh; placed dirs; the image ace runs
bazarr reached sonarr and radarr as `sonarr:8989` and `radarr:7878`, container names on HAL's shared network, which the mesh does not have. It now requires sonarr-api and radarr-api (provided since #156) and a run-once step writes host, port, TLS, base path and key into bazarr through bazarr's own POST /api/system/settings - the call its settings screen makes - only for the fields that differ, and reads them back. bazarr's config.yaml is not written by the mesh: bazarr holds it in memory and rewrites it, so the two would overwrite each other. The key is tried against the app first; a refused key (a minted pair credential before the operator accepts the app's own) is never written, and the step fails naming the `secret accept` that fixes it. Declared last so its failing gates nothing else (ADR 0136); restart-on its four inputs. The api-key own-secret is gone. bazarr makes its own key and nothing lets the mesh set it, so a minted one could never work; the tools and the step read auth.apikey from bazarr's own config/config.yaml (mounted read-only), which also stays right if the key is regenerated. Nothing to accept. The config dir is a pathless ${dir:config}; config.json and the bindings live in a placed state dir; /var/lib/mesh/bazarr keeps only the broker. The image is pinned to the digest ace runs (v1.6.1-ls364); the old pin was v1.6.0-ls361, older than ace's database. Based on feat/servarr-api-provision (#156); this branch contains it. Verified: catalogue tests with MESH_CATALOGUE pass (not skipped); a resolve of sonarr+radarr+bazarr on a fake ace renders both bindings and sealed credentials into the state dir with every ${} filled, and bazarr alone is refused naming sonarr and radarr; strict tsc passes and the Dockerfile's non-strict compile builds; 8 node tests pass; the compiled step against the pinned image in a throwaway container with fake sonarr/radarr wrote sonarr (ip, port, apikey), refused radarr's minted key and wrote nothing for it, wrote radarr once the key was right, and changed nothing on a third run - the values landed in config.yaml.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
// bazarr's Servarr step — run once by the host after bazarr's server starts, and run again whenever a
|
||||
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
|
||||
//
|
||||
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
|
||||
// already knows when they change. It connects to no broker.
|
||||
//
|
||||
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, a
|
||||
// bazarr that would not keep the settings — so the node reports the step failed and the host runs it
|
||||
// again on the next apply. Declared last in the manifest, so its failing gates nothing else of
|
||||
// bazarr's (novox/hq ADR 0136).
|
||||
//
|
||||
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
|
||||
// credential), where <dir> is MESH_SERVARR_DIR; and bazarr's own key from bazarr's own config.yaml
|
||||
// under MESH_BAZARR_CONFIG_DIR. Never prints a key.
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
import { apiKeyFromConfigDir } from "../apikey.js";
|
||||
import { APPS, bazarrReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
|
||||
|
||||
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
|
||||
const url = process.env.MESH_BAZARR_URL ?? "http://127.0.0.1:6767";
|
||||
const waitSeconds = Number(process.env.MESH_BAZARR_WAIT_SECONDS ?? "180");
|
||||
|
||||
const http: Http = { fetch: (u, init) => fetch(u, init) };
|
||||
|
||||
const bazarrUp = await bazarrReady(http, { url, apiKey: "" }, waitSeconds * 1000);
|
||||
if (!bazarrUp) {
|
||||
console.error(`[bazarr-servarr] bazarr did not answer at ${url} within ${waitSeconds}s`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read after bazarr answers: on a first start bazarr writes its config.yaml, key included, as it boots.
|
||||
const apiKey = apiKeyFromConfigDir(process.env.MESH_BAZARR_CONFIG_DIR);
|
||||
if (!apiKey) {
|
||||
console.error("[bazarr-servarr] no bazarr API key in bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR");
|
||||
process.exit(1);
|
||||
}
|
||||
const bazarr = { url, apiKey };
|
||||
|
||||
let failed = 0;
|
||||
for (const spec of APPS) {
|
||||
const outcome = await reconcileApp(
|
||||
http,
|
||||
bazarr,
|
||||
spec,
|
||||
await readBinding(join(dir, `${spec.provision}.json`)),
|
||||
await readIfThere(join(dir, `${spec.provision}.secret`)),
|
||||
);
|
||||
switch (outcome.result) {
|
||||
case "unchanged":
|
||||
console.log(`[bazarr-servarr] ${outcome.app}: already as the mesh says; key taken by ${outcome.app}`);
|
||||
break;
|
||||
case "written":
|
||||
console.log(`[bazarr-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; key taken by ${outcome.app}`);
|
||||
break;
|
||||
case "refused":
|
||||
failed++;
|
||||
console.error(`[bazarr-servarr] ${outcome.app}: ${outcome.problem}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
@@ -0,0 +1,290 @@
|
||||
// Where bazarr reaches Sonarr and Radarr — decided by the mesh, written into bazarr by bazarr's own
|
||||
// API.
|
||||
//
|
||||
// **Why this exists.** bazarr keeps its connection to each app in its own `config/config.yaml`, which
|
||||
// it holds in memory and writes back whenever its settings change — so the mesh cannot own that file
|
||||
// without the two overwriting each other. bazarr requires `sonarr-api` and `radarr-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, novox/hq ADR 0092). This step reads those files and
|
||||
// makes bazarr's settings say the same thing, through `POST /api/system/settings` — the call bazarr's
|
||||
// own settings screen makes, which also restarts bazarr's SignalR feed from the app.
|
||||
//
|
||||
// **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. Whether
|
||||
// bazarr uses the app at all (`general.use_sonarr`), sync intervals, excluded tags, path mappings and
|
||||
// every other choice the operator made are left exactly as they are: the mesh knows where the app is,
|
||||
// not what bazarr should do with it.
|
||||
//
|
||||
// **A credential the app refuses is never written.** Until the operator accepts the app's key for this
|
||||
// pair the mesh delivers a value it minted, which no Servarr app accepts. Writing it would replace a
|
||||
// working key in bazarr 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.
|
||||
//
|
||||
// The same shape as ombi's step (modules/ombi/servarr), repeated rather than shared because a module
|
||||
// is built from its own directory and nothing else (novox/hq ADR 0069). Pure logic and a small HTTP
|
||||
// seam, tested against fakes (test/servarr.test.ts).
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
/** One Servarr app bazarr connects to. */
|
||||
export interface ServarrApp {
|
||||
/** The section of bazarr's settings that holds the connection: settings.<app>.* */
|
||||
app: "sonarr" | "radarr";
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
/** The connection fields bazarr keeps for an app — the only ones this step ever writes. */
|
||||
export interface Connection {
|
||||
ip: string;
|
||||
port: number;
|
||||
ssl: boolean;
|
||||
/** bazarr's base_url: "" at the root, otherwise "/base". */
|
||||
base_url: string;
|
||||
apikey: string;
|
||||
}
|
||||
|
||||
/** What the mesh wrote at `binds.<provision>`: the binding document. */
|
||||
export interface Binding {
|
||||
provision?: string;
|
||||
from?: string;
|
||||
at?: string;
|
||||
as?: string;
|
||||
serves?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string };
|
||||
|
||||
/**
|
||||
* The connection the mesh says bazarr should use. Refused rather than guessed when the binding cannot
|
||||
* be dialled from bazarr's own container: a loopback `at` is bazarr's container itself.
|
||||
*/
|
||||
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 bazarr's own container is ` +
|
||||
`bazarr 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 bazarr 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 bazarr 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", base_url: baseUrlOf(serves["url-base"]), apikey: key },
|
||||
};
|
||||
}
|
||||
|
||||
/** A URL base as bazarr stores it: "" for none, else one leading slash and no trailing one. */
|
||||
export function baseUrlOf(urlBase: unknown): string {
|
||||
const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : "";
|
||||
return trimmed === "" ? "" : `/${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 bazarr holds and what the mesh says. Names only. */
|
||||
export function differing(current: Record<string, unknown> | 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 (baseUrlOf(now.base_url) !== want.base_url) out.push("base_url");
|
||||
if (String(now.apikey ?? "") !== want.apikey) out.push("apikey");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form bazarr's settings endpoint takes for the differing fields: `settings-<app>-<field>`.
|
||||
* bazarr casts "true"/"false" to booleans and digit strings to integers itself.
|
||||
*/
|
||||
export function settingsForm(spec: ServarrApp, want: Connection, fields: readonly (keyof Connection)[]): URLSearchParams {
|
||||
const form = new URLSearchParams();
|
||||
for (const f of fields) {
|
||||
const v = want[f];
|
||||
form.append(`settings-${spec.app}-${f}`, typeof v === "boolean" ? (v ? "true" : "false") : String(v));
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
/** The app's base URL as the step dials it — the same host and port bazarr 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.base_url}`;
|
||||
}
|
||||
|
||||
/** 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 bazarr and the apps. */
|
||||
export interface Http {
|
||||
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{
|
||||
status: number;
|
||||
text(): Promise<string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Bazarr {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
async function bazarrCall(http: Http, bazarr: Bazarr, method: string, path: string, form?: URLSearchParams): Promise<unknown> {
|
||||
const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"X-API-KEY": bazarr.apiKey,
|
||||
Accept: "application/json",
|
||||
...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
|
||||
},
|
||||
body: form ? form.toString() : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
// bazarr's error body is a message, never a request echo, so it carries no key.
|
||||
throw new Error(`bazarr ${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.
|
||||
*/
|
||||
export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise<boolean> {
|
||||
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 bazarr. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` +
|
||||
`key for this pair — \`secret accept <this node> bazarr ${spec.provision} --provider ${from || "<its node>"} ` +
|
||||
`--from <file holding ${spec.app}'s ApiKey>\``
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring bazarr's connection to one app in line with the mesh: check the key against the app, compare,
|
||||
* write only the differing connection fields, then read bazarr's settings back to confirm they took.
|
||||
* Never throws: every failure is an outcome with a reason.
|
||||
*/
|
||||
export async function reconcileApp(
|
||||
http: Http,
|
||||
bazarr: Bazarr,
|
||||
spec: ServarrApp,
|
||||
binding: Binding | undefined,
|
||||
credential: string | undefined,
|
||||
): Promise<Outcome> {
|
||||
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 before = await sectionOf(http, bazarr, spec);
|
||||
const fields = differing(before, want);
|
||||
if (fields.length === 0) return { app: spec.app, result: "unchanged" };
|
||||
await bazarrCall(http, bazarr, "POST", "/system/settings", settingsForm(spec, want, fields));
|
||||
const still = differing(await sectionOf(http, bazarr, spec), want);
|
||||
if (still.length > 0) {
|
||||
return { app: spec.app, result: "refused", problem: `bazarr did not keep its ${spec.app} settings (${still.join(", ")})` };
|
||||
}
|
||||
return { app: spec.app, result: "written", fields };
|
||||
} catch (err) {
|
||||
return { app: spec.app, result: "refused", problem: message(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function sectionOf(http: Http, bazarr: Bazarr, spec: ServarrApp): Promise<Record<string, unknown> | undefined> {
|
||||
const doc = (await bazarrCall(http, bazarr, "GET", "/system/settings")) as Record<string, unknown> | undefined;
|
||||
return doc?.[spec.app] as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/** Wait for bazarr to answer, because the step runs right after its container starts. */
|
||||
export async function bazarrReady(http: Http, bazarr: Bazarr, waitMs: number, pauseMs = 2000): Promise<boolean> {
|
||||
const until = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api/system/ping`, { 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<string | undefined> {
|
||||
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<Binding | undefined> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user