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.
157 lines
7.3 KiB
TypeScript
157 lines
7.3 KiB
TypeScript
// What holds bazarr's Servarr step (servarr/settings.ts): the connection bazarr keeps for Sonarr and
|
|
// Radarr is made to say what the mesh bound — host, port, TLS, base path, key — and nothing else
|
|
// bazarr keeps is sent; nothing is written when nothing differs; and a key the app refuses (the mesh's
|
|
// own minted value, before the operator accepts the app's key) is never written, with the
|
|
// `secret accept` that fixes it named. Also: bazarr's own key is found in its config.yaml's `auth`
|
|
// section and not in the `sonarr`/`radarr` sections that also carry an `apikey`.
|
|
//
|
|
// bazarr and the apps are fakes answering as the real ones do (checked against
|
|
// lscr.io/linuxserver/bazarr v1.6.1-ls364: GET/POST /api/system/settings with X-API-KEY, the form
|
|
// keys `settings-<section>-<field>`, 204 on save).
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { apiKeyFromConfigYaml } from "../apikey.ts";
|
|
import { APPS, baseUrlOf, differing, reconcileApp, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts";
|
|
|
|
const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp;
|
|
const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp;
|
|
const THE_KEY = "the-apps-own-key";
|
|
const BAZARR = { url: "http://127.0.0.1:6767", apiKey: "bazarr-key" };
|
|
|
|
function binding(provision: string, port: number, at = "ace.internal"): Binding {
|
|
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_bazarr", serves: { scheme: "http", port, "url-base": "" } } as Binding;
|
|
}
|
|
|
|
interface Call {
|
|
method: string;
|
|
url: string;
|
|
body?: string;
|
|
}
|
|
|
|
/** bazarr's settings (one document, sections per app) and the apps' key check, behind one fetch. */
|
|
function fakes(settings: Record<string, Record<string, unknown>>, opts: { appKey?: string; reachable?: boolean } = {}) {
|
|
const calls: Call[] = [];
|
|
const appKey = opts.appKey ?? THE_KEY;
|
|
const http: Http = {
|
|
async fetch(url, init) {
|
|
const method = init?.method ?? "GET";
|
|
calls.push({ method, url, body: init?.body });
|
|
const reply = (status: number, value?: unknown) => ({
|
|
status,
|
|
text: async () => (value === undefined ? "" : JSON.stringify(value)),
|
|
});
|
|
const u = new URL(url);
|
|
if (u.pathname.endsWith("/system/status")) {
|
|
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
|
|
return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401);
|
|
}
|
|
if (init?.headers?.["X-API-KEY"] !== BAZARR.apiKey) return reply(401);
|
|
if (u.pathname !== "/api/system/settings") return reply(404);
|
|
if (method === "GET") return reply(200, settings);
|
|
// bazarr's save_settings: split the key, cast as bazarr casts, store.
|
|
for (const [k, raw] of new URLSearchParams(init?.body ?? "")) {
|
|
const [, section, field] = k.split("-");
|
|
let v: unknown = raw;
|
|
if (raw === "true") v = true;
|
|
else if (raw === "false") v = false;
|
|
else if (/^\d+$/.test(raw)) v = Number(raw);
|
|
settings[section] = { ...(settings[section] ?? {}), [field]: v };
|
|
}
|
|
return reply(204);
|
|
},
|
|
};
|
|
return { http, calls, settings };
|
|
}
|
|
|
|
/** ace's bazarr today: the apps by container name on HAL's shared network. */
|
|
function aceToday(): Record<string, Record<string, unknown>> {
|
|
return {
|
|
general: { use_sonarr: true, use_radarr: true, port: 6767 },
|
|
sonarr: { ip: "sonarr", port: 8989, ssl: false, base_url: "", apikey: THE_KEY, series_sync: 15, excluded_series_types: ["anime"] },
|
|
radarr: { ip: "radarr", port: 7878, ssl: false, base_url: "", apikey: THE_KEY, movies_sync: 15 },
|
|
};
|
|
}
|
|
|
|
test("moving an app writes only host and port, and leaves every other setting alone", async () => {
|
|
const f = fakes(aceToday());
|
|
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
|
|
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port"] });
|
|
const post = f.calls.find((c) => c.method === "POST");
|
|
assert.ok(post);
|
|
assert.deepEqual([...new URLSearchParams(post.body ?? "").keys()].sort(), ["settings-sonarr-ip", "settings-sonarr-port"]);
|
|
assert.equal(f.settings.sonarr.ip, "ace.internal");
|
|
assert.equal(f.settings.sonarr.port, 20010);
|
|
assert.deepEqual(f.settings.sonarr.excluded_series_types, ["anime"]);
|
|
assert.equal(f.settings.radarr.ip, "radarr", "radarr is its own app and was not touched");
|
|
});
|
|
|
|
test("nothing is written when bazarr already says what the mesh says", async () => {
|
|
const s = aceToday();
|
|
s.radarr = { ...s.radarr, ip: "ace.internal", port: 20011 };
|
|
const f = fakes(s);
|
|
const out = await reconcileApp(f.http, BAZARR, RADARR, binding("radarr-api", 20011), THE_KEY);
|
|
assert.deepEqual(out, { app: "radarr", result: "unchanged" });
|
|
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
|
|
});
|
|
|
|
test("a key the app refuses is never written, and the remedy is named", async () => {
|
|
const f = fakes(aceToday());
|
|
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), "a-value-the-mesh-minted");
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /secret accept <this node> bazarr sonarr-api --provider ace/);
|
|
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
|
|
assert.equal(f.settings.sonarr.apikey, THE_KEY, "the working key stays");
|
|
assert.equal(f.settings.sonarr.ip, "sonarr", "nothing moved either");
|
|
});
|
|
|
|
test("an unreachable app writes nothing", async () => {
|
|
const f = fakes(aceToday(), { reachable: false });
|
|
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
|
|
assert.equal(out.result, "refused");
|
|
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
|
|
});
|
|
|
|
test("a loopback binding is refused: from bazarr's container that is bazarr", () => {
|
|
const w = wanted(SONARR, binding("sonarr-api", 8989, "127.0.0.1"), THE_KEY);
|
|
assert.equal(w.ok, false);
|
|
});
|
|
|
|
test("a new key is written when the operator accepted a different one", async () => {
|
|
const s = aceToday();
|
|
s.sonarr = { ...s.sonarr, ip: "ace.internal", port: 20010, apikey: "an-old-key" };
|
|
const f = fakes(s);
|
|
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
|
|
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["apikey"] });
|
|
assert.equal(f.settings.sonarr.apikey, THE_KEY);
|
|
});
|
|
|
|
test("base paths compare as bazarr stores them", () => {
|
|
assert.equal(baseUrlOf(""), "");
|
|
assert.equal(baseUrlOf("/"), "");
|
|
assert.equal(baseUrlOf("sonarr/"), "/sonarr");
|
|
const want = { ip: "a", port: 1, ssl: false, base_url: "", apikey: "k" };
|
|
assert.deepEqual(differing({ ip: "a", port: 1, ssl: false, base_url: "/", apikey: "k" }, want), []);
|
|
});
|
|
|
|
test("bazarr's own key is auth.apikey, not an app's", () => {
|
|
const yaml = [
|
|
"analytics:",
|
|
" enabled: false",
|
|
"auth:",
|
|
" apikey: 0123456789abcdef0123456789abcdef",
|
|
" password: ''",
|
|
" type: form",
|
|
"general:",
|
|
" port: 6767",
|
|
"sonarr:",
|
|
" apikey: not-this-one",
|
|
"",
|
|
].join("\n");
|
|
assert.equal(apiKeyFromConfigYaml(yaml), "0123456789abcdef0123456789abcdef");
|
|
assert.equal(apiKeyFromConfigYaml("sonarr:\n apikey: x\n"), undefined);
|
|
assert.equal(apiKeyFromConfigYaml("auth:\n apikey: ''\n"), undefined);
|
|
assert.equal(apiKeyFromConfigYaml("auth:\r\n apikey: 'abc'\r\n"), "abc");
|
|
});
|