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.
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
// 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);
|
|
}
|