sonarr, radarr, lidarr, bazarr, nzbget, qbittorrent, jackett, bookshelf, plex, tautulli, kometa and ombi, taken from the novox/mesh-catalog branches that prepared them for ace (PRs 145-168), consolidated in stack order. kometa gains a minimal runtime sidecar (kometa_status, kometa_config) and declares its tmdb key as an own secret instead of a "secret" requirement.
148 lines
7.3 KiB
TypeScript
148 lines
7.3 KiB
TypeScript
// What holds ombi's Servarr step (servarr/settings.ts): the connection ombi keeps for each app is
|
|
// made to say what the mesh bound — host, port, TLS, base path, key — and nothing else it keeps is
|
|
// touched; nothing is written when nothing differs; Radarr's 4K instance is left alone; 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.
|
|
//
|
|
// ombi and the apps are fakes: the routes the step touches, answering as the real ones do (checked
|
|
// against lscr.io/linuxserver/ombi 4.53.10 and the catalogue's pinned sonarr/radarr/lidarr).
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { APPS, differing, reconcileApp, subDirOf, 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 LIDARR = APPS.find((a) => a.app === "lidarr") as ServarrApp;
|
|
const THE_KEY = "the-apps-own-key";
|
|
|
|
function binding(provision: string, port: number, at = "ace.internal"): Binding {
|
|
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_ombi", serves: { scheme: "http", port, "url-base": "" } } as Binding;
|
|
}
|
|
|
|
interface Call {
|
|
method: string;
|
|
url: string;
|
|
body?: unknown;
|
|
}
|
|
|
|
/** ombi's settings store and the apps' key check, behind one fetch. */
|
|
function fakes(settings: 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";
|
|
const body = init?.body ? (JSON.parse(init.body) as unknown) : undefined;
|
|
calls.push({ method, url, 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?.ApiKey !== "ombi-key") return reply(401);
|
|
const m = u.pathname.match(/^\/api\/v1\/(Settings|Tester)\/(\w+)$/);
|
|
if (!m) return reply(404);
|
|
const [, kind, app] = m;
|
|
if (kind === "Settings" && method === "GET") return reply(200, settings[app]);
|
|
if (kind === "Settings" && method === "POST") {
|
|
settings[app] = body;
|
|
return reply(200, true);
|
|
}
|
|
const tried = body as { apiKey?: string };
|
|
return reply(200, { isValid: tried.apiKey === appKey, expectedSubDir: null });
|
|
},
|
|
};
|
|
return { http, calls, settings };
|
|
}
|
|
|
|
const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" };
|
|
|
|
const operatorSonarr = () => ({
|
|
enabled: true, apiKey: "old-key", qualityProfile: "3", seasonFolders: true, rootPath: "10",
|
|
qualityProfileAnime: "7", rootPathAnime: "9", languageProfile: 1, ssl: false, subDir: null,
|
|
ip: "sonarr", port: 8989, id: 5,
|
|
});
|
|
|
|
test("it writes the connection the mesh bound, and keeps every other setting ombi had", async () => {
|
|
const f = fakes({ sonarr: operatorSonarr() });
|
|
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), `${THE_KEY}\n`);
|
|
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port", "apiKey"] });
|
|
assert.deepEqual(f.settings.sonarr, {
|
|
...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY, ssl: false, subDir: null,
|
|
});
|
|
// Checked against the app itself, at the bound address, before anything was written.
|
|
assert.equal(f.calls[0].url, "http://ace.internal:20101/api/v3/system/status");
|
|
});
|
|
|
|
test("nothing is written when ombi already says what the mesh says", async () => {
|
|
const f = fakes({ sonarr: { ...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY } });
|
|
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
|
|
assert.deepEqual(out, { app: "sonarr", result: "unchanged" });
|
|
assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0);
|
|
});
|
|
|
|
test("a key the app refuses is never written, and the accept that fixes it is named", async () => {
|
|
const f = fakes({ sonarr: operatorSonarr() });
|
|
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), "a-value-the-mesh-minted");
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /secret accept <this node> ombi sonarr-api --provider ace/);
|
|
assert.doesNotMatch((out as { problem: string }).problem, /a-value-the-mesh-minted/);
|
|
assert.deepEqual(f.settings.sonarr, operatorSonarr(), "ombi's working settings were left alone");
|
|
assert.equal(f.calls.some((c) => c.url.includes("/api/v1/")), false, "ombi was not even asked");
|
|
});
|
|
|
|
test("an app it cannot reach is reported, and ombi is left alone", async () => {
|
|
const f = fakes({ sonarr: operatorSonarr() }, { reachable: false });
|
|
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/);
|
|
assert.deepEqual(f.settings.sonarr, operatorSonarr());
|
|
});
|
|
|
|
test("radarr's connection is written inside its combined document, and the 4K instance is untouched", async () => {
|
|
const fourK = { enabled: true, apiKey: "4k-key", ip: "radarr4k", port: 7879, defaultQualityProfile: "9", id: 7 };
|
|
const f = fakes({ radarr: { radarr: { enabled: true, apiKey: "old", ip: "radarr", port: 7878, defaultRootPath: "/movies", id: 6 }, radarr4K: fourK } });
|
|
const out = await reconcileApp(f.http, OMBI, RADARR, binding("radarr-api", 20102), THE_KEY);
|
|
assert.equal(out.result, "written");
|
|
const doc = f.settings.radarr as { radarr: Record<string, unknown>; radarr4K: unknown };
|
|
assert.deepEqual(doc.radarr4K, fourK);
|
|
assert.equal(doc.radarr.ip, "ace.internal");
|
|
assert.equal(doc.radarr.port, 20102);
|
|
assert.equal(doc.radarr.defaultRootPath, "/movies");
|
|
});
|
|
|
|
test("lidarr is checked on its own API version", async () => {
|
|
const f = fakes({ lidarr: { enabled: true, apiKey: null, ip: null, port: 0, id: 0 } });
|
|
const out = await reconcileApp(f.http, OMBI, LIDARR, binding("lidarr-api", 20103), THE_KEY);
|
|
assert.equal(out.result, "written");
|
|
assert.equal(f.calls[0].url, "http://ace.internal:20103/api/v1/system/status");
|
|
});
|
|
|
|
test("a loopback binding is refused: from ombi's container it is ombi itself", () => {
|
|
const w = wanted(SONARR, binding("sonarr-api", 20101, "127.0.0.1"), THE_KEY);
|
|
assert.equal(w.ok, false);
|
|
assert.match((w as { problem: string }).problem, /private network/);
|
|
});
|
|
|
|
test("the base path is ombi's subDir, slashes trimmed; empty is none", () => {
|
|
assert.equal(subDirOf(""), null);
|
|
assert.equal(subDirOf("/sonarr/"), "sonarr");
|
|
assert.deepEqual(
|
|
differing({ ip: "h", port: 1, ssl: false, subDir: "", apiKey: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, apiKey: "k" }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("an https binding sets ombi's ssl flag", () => {
|
|
const b = binding("sonarr-api", 443);
|
|
(b.serves as Record<string, unknown>).scheme = "https";
|
|
const w = wanted(SONARR, b, THE_KEY);
|
|
assert.equal(w.ok && w.connection.ssl, true);
|
|
});
|