ombi reached plex at its public name, typed into its settings screen, so it depended on plex's public route and on nobody moving plex. ombi now requires plex-api, and the run-once step that writes its Servarr connections writes its Plex one too - renamed from `servarr` to `connections`, since it is no longer only that. ombi keeps several Plex servers. The entry this provision names is found by the server's own machineIdentifier (plex answers it at /identity, and ombi stored it when the server was loaded), and only its host, port, TLS, base path and token are written, only when they differ. Another server's entry, the selected libraries, whether Plex is enabled and every other choice are left alone. An ombi with no entry for the server gets one. The token is tried against plex first. Until the operator accepts the server's X-Plex-Token for this pair the mesh delivers a value it minted, which plex refuses (401, or 400 on a network it trusts); refused, nothing is written and the step fails naming the secret accept, so a working token in ombi is never replaced by a dead one. Tests import the compiled step, as keycloak's do: the step imports its sibling with the .js specifier the build needs, which type stripping does not resolve. `npm test` builds first.
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
// 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/<app>, /Tester/<app>. */
|
|
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.<provision>`: the binding document (controller's boundFile). */
|
|
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 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;
|
|
}
|
|
|
|
export 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<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 (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<string, unknown> | undefined, want: Connection): Record<string, unknown> {
|
|
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<string, string>; body?: string }): Promise<{
|
|
status: number;
|
|
text(): Promise<string>;
|
|
}>;
|
|
}
|
|
|
|
export interface Ombi {
|
|
url: string;
|
|
apiKey: string;
|
|
}
|
|
|
|
export async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise<unknown> {
|
|
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<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 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 <this node> ombi ${spec.provision} --provider ${from || "<its node>"} ` +
|
|
`--from <file holding ${spec.app}'s ApiKey>\``
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<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 document = (await ombiCall(http, ombi, "GET", `/Settings/${spec.app}`)) as Record<string, unknown> | undefined;
|
|
const current = spec.within ? (document?.[spec.within] as Record<string, unknown> | 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<boolean> {
|
|
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<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);
|
|
}
|