Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8148678af8 | ||
|
|
f14c763463 | ||
|
|
ab44ff02e1 | ||
|
|
c4c44efb1b | ||
|
|
5cc6258326 | ||
|
|
21f5301268 | ||
|
|
8064e5da8f | ||
|
|
63a255c5cb | ||
|
|
7ad1fbd5c6 | ||
|
|
67f5f4cffd | ||
|
|
8797335fbc | ||
|
|
53dc108603 | ||
|
|
ebf5ba2d4c | ||
|
|
bbac08a7d2 | ||
|
|
784a5a6514 | ||
|
|
0c31499fb0 | ||
|
|
f118344246 | ||
|
|
822df220ab | ||
|
|
9eb1265bc8 | ||
|
|
41cfc70b53 | ||
|
|
acedc5d9d9 | ||
|
|
521a8dd1e2 | ||
|
|
e145e2236c | ||
|
|
4d7e37e319 | ||
|
|
026421fd6e | ||
|
|
af89bb11ff | ||
|
|
7c18cdbd39 | ||
|
|
4fb16b2e6b | ||
|
|
c5af8635c8 | ||
|
|
87366c5f36 | ||
|
|
f8ca36aacf | ||
|
|
812355bf31 | ||
|
|
016ddb2b3a | ||
|
|
ea17bf46d2 | ||
|
|
4258f01614 | ||
|
|
4d9b4fdfa6 | ||
|
|
eff11b1d4d | ||
|
|
4ead13d4d4 | ||
|
|
3b77dde666 | ||
|
|
5ea4961980 | ||
|
|
2b8a668d06 | ||
|
|
719fb1e025 | ||
|
|
ac5630bee2 | ||
|
|
1c995fa9fc | ||
|
|
d70cb18ea0 | ||
|
|
1d71787896 | ||
|
|
eb62289f89 | ||
|
|
f5969a2f9f | ||
|
|
7f3d259cf5 | ||
|
|
721149eda1 | ||
|
|
8e27bc1e36 | ||
|
|
f1212620e4 | ||
|
|
b1b18ae390 |
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "baserow",
|
"label": "baserow",
|
||||||
"port": 80
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
|||||||
FROM ${BUILD_BASE} AS build
|
FROM ${BUILD_BASE} AS build
|
||||||
WORKDIR /app/modules/bazarr
|
WORKDIR /app/modules/bazarr
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
RUN node /app/node_modules/typescript/bin/tsc apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
FROM ${RUNTIME_BASE}
|
||||||
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/bazarr/dist /app/modules/bazarr/dist
|
|||||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||||
# the convention novox/hq issues 060/061 settled.
|
# the convention novox/hq issues 060/061 settled.
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js
|
ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js
|
||||||
|
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
|
||||||
|
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
|
||||||
|
# serving sidecar too, and exit it.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// bazarr's own API key, found where bazarr keeps it. Shared by the client (tools, events) and the
|
||||||
|
// Servarr step, and kept apart from client.ts so the step and its test load it without the client.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bazarr's own API key, read from where bazarr keeps it: `auth.apikey` in `config/config.yaml` under
|
||||||
|
* its config directory. bazarr makes this key itself on first start and nothing lets the mesh set it,
|
||||||
|
* so a key the mesh minted could never work; reading bazarr's own file needs nothing accepted and
|
||||||
|
* stays right if the operator regenerates the key in bazarr's settings screen. The file is read, never
|
||||||
|
* written. Undefined when the file or the key is not there.
|
||||||
|
*/
|
||||||
|
export function apiKeyFromConfigDir(configDir?: string): string | undefined {
|
||||||
|
if (!configDir) return undefined;
|
||||||
|
let text: string;
|
||||||
|
try { text = readFileSync(`${configDir.replace(/\/$/, "")}/config/config.yaml`, "utf8"); }
|
||||||
|
catch { return undefined; }
|
||||||
|
return apiKeyFromConfigYaml(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `auth.apikey` from the text of bazarr's config.yaml — a top-level `auth:` mapping, one level deep. */
|
||||||
|
export function apiKeyFromConfigYaml(text: string): string | undefined {
|
||||||
|
let inAuth = false;
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
if (/^\S/.test(line)) {
|
||||||
|
inAuth = /^auth:\s*$/.test(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inAuth) continue;
|
||||||
|
const m = line.match(/^\s+apikey:\s*(.*?)\s*$/);
|
||||||
|
if (m) {
|
||||||
|
const v = m[1].replace(/^(['"])(.*)\1$/, "$2").trim();
|
||||||
|
return v || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { apiKeyFromConfigDir } from "./apikey.js";
|
||||||
|
|
||||||
export interface WantedSubtitle {
|
export interface WantedSubtitle {
|
||||||
kind: "episode" | "movie";
|
kind: "episode" | "movie";
|
||||||
title: string; // series + episode, or movie title
|
title: string; // series + episode, or movie title
|
||||||
@@ -47,7 +49,7 @@ function meshConfig(file?: string): Record<string, string> {
|
|||||||
* absent or unreadable yields undefined so callers fall back rather than crash. */
|
* absent or unreadable yields undefined so callers fall back rather than crash. */
|
||||||
function readSecret(file?: string): string | undefined {
|
function readSecret(file?: string): string | undefined {
|
||||||
if (!file) return undefined;
|
if (!file) return undefined;
|
||||||
try { return readFileSync(file, "utf8").trim(); }
|
try { return readFileSync(file, "utf8").trim() || undefined; }
|
||||||
catch { return undefined; }
|
catch { return undefined; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +68,13 @@ export class BazarrClient {
|
|||||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
|
||||||
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
|
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
|
||||||
const url = cfg.url ?? env.MESH_BAZARR_URL;
|
const url = cfg.url ?? env.MESH_BAZARR_URL;
|
||||||
const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? env.MESH_BAZARR_API_KEY;
|
const apiKey =
|
||||||
|
cfg.apiKey ??
|
||||||
|
apiKeyFromConfigDir(env.MESH_BAZARR_CONFIG_DIR) ??
|
||||||
|
readSecret(env.MESH_BAZARR_API_KEY_FILE) ??
|
||||||
|
env.MESH_BAZARR_API_KEY;
|
||||||
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
|
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
|
||||||
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
|
if (!apiKey) throw new Error("no Bazarr API key — bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR has none");
|
||||||
return new BazarrClient(url, apiKey);
|
return new BazarrClient(url, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-14
@@ -8,11 +8,11 @@
|
|||||||
"subtitle.downloaded"
|
"subtitle.downloaded"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"broker": "/var/lib/mesh/bazarr/broker",
|
"broker": "/var/lib/mesh/bazarr/broker"
|
||||||
"api-key": "/var/lib/mesh/bazarr/api-key"
|
|
||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 6767,
|
"port": 6767,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -44,10 +44,15 @@
|
|||||||
"path": "/var/lib/mesh/bazarr",
|
"path": "/var/lib/mesh/bazarr",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"place": "."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "config",
|
"id": "config",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/services/bazarr/config",
|
|
||||||
"mode": "0700",
|
"mode": "0700",
|
||||||
"owner": "1000:1000"
|
"owner": "1000:1000"
|
||||||
},
|
},
|
||||||
@@ -55,7 +60,7 @@
|
|||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "bazarr",
|
"name": "bazarr",
|
||||||
"image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02",
|
"image": "lscr.io/linuxserver/bazarr@sha256:d24bd0048c759a468970989e9df11a6b96a7628d556d00f923e60a35ba59237b",
|
||||||
"env": {
|
"env": {
|
||||||
"PUID": "1000",
|
"PUID": "1000",
|
||||||
"PGID": "1000",
|
"PGID": "1000",
|
||||||
@@ -65,7 +70,7 @@
|
|||||||
"6767"
|
"6767"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/bazarr/config:/config",
|
"${dir:config}:/config",
|
||||||
"/services/media/movies:/movies",
|
"/services/media/movies:/movies",
|
||||||
"/services/media/series:/series",
|
"/services/media/series:/series",
|
||||||
"/services/media/anime:/anime",
|
"/services/media/anime:/anime",
|
||||||
@@ -75,7 +80,7 @@
|
|||||||
{
|
{
|
||||||
"id": "runtime-config",
|
"id": "runtime-config",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mesh/bazarr/config.json",
|
"path": "${dir:state}/config.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "{}\n",
|
"content": "{}\n",
|
||||||
"merge": "json"
|
"merge": "json"
|
||||||
@@ -87,14 +92,12 @@
|
|||||||
"network": "host",
|
"network": "host",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/mesh/bazarr/api-key:/run/secrets/api-key:ro",
|
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||||
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
|
"${dir:config}:/var/lib/bazarr/config:ro"
|
||||||
"/services/bazarr/config:/var/lib/bazarr/config:ro"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
|
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
|
||||||
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
|
|
||||||
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
|
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
|
||||||
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
|
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
|
||||||
},
|
},
|
||||||
@@ -102,19 +105,57 @@
|
|||||||
"runtime-config"
|
"runtime-config"
|
||||||
],
|
],
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "servarr",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mesh-bazarr-servarr",
|
||||||
|
"network": "host",
|
||||||
|
"run-once": true,
|
||||||
|
"volumes": [
|
||||||
|
"${dir:config}:/var/lib/bazarr/config:ro",
|
||||||
|
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
|
||||||
|
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
|
||||||
|
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
|
||||||
|
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
|
||||||
|
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config",
|
||||||
|
"MESH_SERVARR_DIR": "/run/servarr"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"/app/modules/bazarr/dist/servarr/index.js"
|
||||||
|
],
|
||||||
|
"restart-on": [
|
||||||
|
"bound-sonarr-api",
|
||||||
|
"secret-sonarr-api",
|
||||||
|
"bound-radarr-api",
|
||||||
|
"secret-radarr-api"
|
||||||
|
],
|
||||||
|
"artifact": "runtime"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requires": [
|
"requires": [
|
||||||
"route"
|
"radarr-api",
|
||||||
|
"route",
|
||||||
|
"sonarr-api"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "subs",
|
"label": "subs",
|
||||||
"port": 6767
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
"route": "/var/lib/mesh/bazarr/route.json"
|
"route": "${dir:state}/route.json",
|
||||||
|
"sonarr-api": "${dir:state}/sonarr-api.json",
|
||||||
|
"radarr-api": "${dir:state}/radarr-api.json"
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"sonarr-api": "${dir:state}/sonarr-api.secret",
|
||||||
|
"radarr-api": "${dir:state}/radarr-api.secret"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
"on": [
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
"description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).",
|
"description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
"include": ["apikey.ts", "client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8787,
|
"port": 8787,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
|
"MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}",
|
||||||
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
|
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
|
||||||
},
|
},
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "books",
|
"label": "books",
|
||||||
"port": 8787
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"module": "ca-trust",
|
||||||
|
"version": "1",
|
||||||
|
"slug": "catrust",
|
||||||
|
"capabilities": [
|
||||||
|
"service-manager"
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
"internal-acme-ca"
|
||||||
|
],
|
||||||
|
"seats": [
|
||||||
|
{
|
||||||
|
"name": "the-mesh-trust-anchor",
|
||||||
|
"scope": "node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "the-mesh-trust-anchor",
|
||||||
|
"scope": "node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"place": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anchor",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/anchor",
|
||||||
|
"mode": "0755",
|
||||||
|
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/etc/systemd/system/mesh-ca-trust.service",
|
||||||
|
"mode": "0644",
|
||||||
|
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trust",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "mesh-ca-trust.service",
|
||||||
|
"state": "running",
|
||||||
|
"boot": "enabled",
|
||||||
|
"restart-on": [
|
||||||
|
"anchor",
|
||||||
|
"unit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "de-spiegel",
|
"label": "de-spiegel",
|
||||||
"port": 35621
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 35621,
|
"port": 35621,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "registry",
|
||||||
"port": 5000,
|
"port": 5000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -22,11 +22,20 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "dns-udp",
|
||||||
"port": 53,
|
"port": 53,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded",
|
"why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dns-tcp",
|
||||||
|
"port": 53,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success",
|
||||||
|
"fixed": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/fail2ban/jail.local",
|
"path": "/etc/fail2ban/jail.local",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jail-sshd",
|
"id": "jail-sshd",
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
|
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "log",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/var/log/fail2ban.log",
|
||||||
|
"mode": "0640",
|
||||||
|
"create-once": true,
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "jail-recidive",
|
"id": "jail-recidive",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
|
|||||||
+35
-2
@@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
|||||||
/** A repository, trimmed to what the mesh cares about. */
|
/** A repository, trimmed to what the mesh cares about. */
|
||||||
export interface GiteaRepo {
|
export interface GiteaRepo {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
|
/** The URL a build clones — what a module records as its source. */
|
||||||
|
clone_url?: string;
|
||||||
name: string;
|
name: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
@@ -34,6 +36,9 @@ export interface GiteaPull {
|
|||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
merged: boolean;
|
merged: boolean;
|
||||||
|
/** The commit the merge produced — what a build of the base branch is made from. */
|
||||||
|
merge_commit_sha?: string;
|
||||||
|
merged_at?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
head?: string;
|
head?: string;
|
||||||
base?: string;
|
base?: string;
|
||||||
@@ -116,9 +121,23 @@ export class GiteaClient {
|
|||||||
|
|
||||||
// ---- Repositories ----
|
// ---- Repositories ----
|
||||||
|
|
||||||
|
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
|
||||||
|
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
|
||||||
|
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
|
||||||
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
||||||
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
|
||||||
return (repos ?? []).map(GiteaClient.mapRepo);
|
return (found?.data ?? []).map(GiteaClient.mapRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every repository, all pages. */
|
||||||
|
async listAllRepos(): Promise<GiteaRepo[]> {
|
||||||
|
const all: GiteaRepo[] = [];
|
||||||
|
for (let page = 1; page < 100; page++) {
|
||||||
|
const batch = await this.listRepos(page, 50);
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 50) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRepo(data: {
|
async createRepo(data: {
|
||||||
@@ -210,6 +229,17 @@ export class GiteaClient {
|
|||||||
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
|
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The files a merged pull request changed, as paths from the repository's root.
|
||||||
|
*
|
||||||
|
* `limit` is what is asked for, and a merge that changed more says so rather than being read
|
||||||
|
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
|
||||||
|
* so more pages would buy nothing. */
|
||||||
|
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
|
||||||
|
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
|
||||||
|
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
|
||||||
|
return { paths, truncated: paths.length >= limit };
|
||||||
|
}
|
||||||
|
|
||||||
async createPullRequest(
|
async createPullRequest(
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
@@ -232,6 +262,7 @@ export class GiteaClient {
|
|||||||
private static mapRepo(r: any): GiteaRepo {
|
private static mapRepo(r: any): GiteaRepo {
|
||||||
return {
|
return {
|
||||||
full_name: r.full_name,
|
full_name: r.full_name,
|
||||||
|
clone_url: r.clone_url ?? undefined,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
||||||
private: Boolean(r.private),
|
private: Boolean(r.private),
|
||||||
@@ -259,6 +290,8 @@ export class GiteaClient {
|
|||||||
title: p.title,
|
title: p.title,
|
||||||
state: p.state,
|
state: p.state,
|
||||||
merged: Boolean(p.merged),
|
merged: Boolean(p.merged),
|
||||||
|
merge_commit_sha: p.merge_commit_sha ?? undefined,
|
||||||
|
merged_at: p.merged_at ?? undefined,
|
||||||
user: p.user?.login,
|
user: p.user?.login,
|
||||||
head: p.head?.ref,
|
head: p.head?.ref,
|
||||||
base: p.base?.ref,
|
base: p.base?.ref,
|
||||||
|
|||||||
+80
-2
@@ -31,7 +31,7 @@ try {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let primed = false;
|
let primed = false;
|
||||||
async function pollRepos(client: GiteaClient): Promise<void> {
|
async function pollRepos(client: GiteaClient): Promise<void> {
|
||||||
const repos = await client.listRepos(1, 50);
|
const repos = await client.listAllRepos();
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
if (!seen.has(repo.full_name)) {
|
if (!seen.has(repo.full_name)) {
|
||||||
if (primed) {
|
if (primed) {
|
||||||
@@ -49,6 +49,83 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
|||||||
primed = true;
|
primed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
|
||||||
|
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
|
||||||
|
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
|
||||||
|
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
|
||||||
|
// What has been announced is kept beside the module's state, so a restart does not announce the
|
||||||
|
// whole history again — and the first tick on a machine with no record announces nothing, because
|
||||||
|
// everything it sees then predates the watching.
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
||||||
|
const announced = new Set<string>();
|
||||||
|
let primedMerges = false;
|
||||||
|
// since is the moment the watching began: a merge made before it is history, whatever page of the
|
||||||
|
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
|
||||||
|
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
|
||||||
|
// rebuilt everything built from that repository, once per old merge (2026-09-28).
|
||||||
|
let since = "";
|
||||||
|
if (mergedRecord && existsSync(mergedRecord)) {
|
||||||
|
try {
|
||||||
|
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
|
||||||
|
const list = Array.isArray(kept) ? kept : kept.announced;
|
||||||
|
for (const sha of list) announced.add(sha);
|
||||||
|
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
|
||||||
|
primedMerges = true;
|
||||||
|
} catch {
|
||||||
|
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function keepAnnounced(): void {
|
||||||
|
if (!mergedRecord) return;
|
||||||
|
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
||||||
|
const tmp = mergedRecord + ".tmp";
|
||||||
|
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||||
|
renameSync(tmp, mergedRecord);
|
||||||
|
}
|
||||||
|
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||||
|
const repos = await client.listAllRepos();
|
||||||
|
let changed = false;
|
||||||
|
for (const repo of repos) {
|
||||||
|
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||||
|
for (const pull of pulls) {
|
||||||
|
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||||
|
// Announced only if merged since the watching began; recorded either way, so it is looked
|
||||||
|
// at once.
|
||||||
|
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
||||||
|
if (primedMerges && fresh) {
|
||||||
|
// What it changed, asked for only now: a module is rebuilt because a file inside its own
|
||||||
|
// directory moved, and without this every module built from a repository is rebuilt for a
|
||||||
|
// change to any of them (novox/hq 04-ISSUES/131).
|
||||||
|
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||||
|
await emit("pull.merged", {
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.name,
|
||||||
|
number: pull.number,
|
||||||
|
title: pull.title,
|
||||||
|
head: pull.head,
|
||||||
|
base: pull.base,
|
||||||
|
merge_commit_sha: pull.merge_commit_sha,
|
||||||
|
merged_at: pull.merged_at,
|
||||||
|
clone_url: repo.clone_url,
|
||||||
|
html_url: pull.html_url,
|
||||||
|
paths: changed.paths,
|
||||||
|
paths_truncated: changed.truncated,
|
||||||
|
});
|
||||||
|
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||||
|
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||||
|
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
|
||||||
|
}
|
||||||
|
announced.add(pull.merge_commit_sha);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!primedMerges) since = new Date().toISOString();
|
||||||
|
if (!primedMerges || changed) keepAnnounced();
|
||||||
|
primedMerges = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (gitea) {
|
if (gitea) {
|
||||||
const client = gitea;
|
const client = gitea;
|
||||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||||
@@ -70,5 +147,6 @@ if (gitea) {
|
|||||||
run();
|
run();
|
||||||
};
|
};
|
||||||
tick(() => pollRepos(client), 60_000);
|
tick(() => pollRepos(client), 60_000);
|
||||||
console.log("[gitea] watching for new repositories");
|
tick(() => pollMerged(client), 30_000);
|
||||||
|
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"web": {
|
"web": {
|
||||||
"label": "git",
|
"label": "git",
|
||||||
"port": 3000
|
"endpoint": "web"
|
||||||
},
|
},
|
||||||
"internal-api-refused": {
|
"internal-api-refused": {
|
||||||
"label": "git",
|
"label": "git",
|
||||||
@@ -44,12 +44,14 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the forge, over http"
|
"why": "the forge, over http"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "ssh",
|
||||||
"port": 22,
|
"port": 22,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||||
|
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
||||||
|
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||||
|
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
|
||||||
|
// every module built from the repository (novox/hq 04-ISSUES/131).
|
||||||
|
const changed = await gitea.listPullFiles(owner, repo, number);
|
||||||
await emit("pull.merged", {
|
await emit("pull.merged", {
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -238,8 +243,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
title: pull.title,
|
title: pull.title,
|
||||||
head: pull.head,
|
head: pull.head,
|
||||||
base: pull.base,
|
base: pull.base,
|
||||||
|
merge_commit_sha: merged.merge_commit_sha,
|
||||||
|
merged_at: merged.merged_at,
|
||||||
method,
|
method,
|
||||||
html_url: pull.html_url,
|
html_url: pull.html_url,
|
||||||
|
paths: changed.paths,
|
||||||
|
paths_truncated: changed.truncated,
|
||||||
});
|
});
|
||||||
return { merged: true, number, method, deleted_branch: deleteBranch };
|
return { merged: true, number, method, deleted_branch: deleteBranch };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "grafana",
|
"label": "grafana",
|
||||||
"port": 3000
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "hello",
|
"label": "hello",
|
||||||
"port": 8080
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8123,
|
"port": 8123,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "home-assistant",
|
"label": "home-assistant",
|
||||||
"port": 8123
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "stream",
|
||||||
"port": 8000,
|
"port": 8000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"site": {
|
"site": {
|
||||||
"label": "invoicing",
|
"label": "invoicing",
|
||||||
"port": 80
|
"endpoint": "web"
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"label": "invoicing-api",
|
"label": "invoicing-api",
|
||||||
"port": 9000
|
"endpoint": "api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -36,12 +36,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the invoicing web frontend; a public name is a route grant later"
|
"why": "the invoicing web frontend; a public name is a route grant later"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 9117,
|
"port": 9117,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "indexers",
|
"label": "indexers",
|
||||||
"port": 9117
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "keycloak",
|
"label": "keycloak",
|
||||||
"port": 8080
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8283,
|
"port": 8283,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
{
|
{
|
||||||
"module": "lidarr",
|
"module": "lidarr",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "lidarr-api",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serves": {
|
||||||
|
"lidarr-api": {
|
||||||
|
"scheme": "http",
|
||||||
|
"port": 8686,
|
||||||
|
"url-base": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
@@ -14,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8686,
|
"port": 8686,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -74,7 +88,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_LIDARR_URL": "http://127.0.0.1:8686",
|
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
|
||||||
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
|
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
|
||||||
},
|
},
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
@@ -86,7 +100,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "lidarr",
|
"label": "lidarr",
|
||||||
"port": 8686
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -16,27 +16,27 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"web": {
|
"web": {
|
||||||
"label": "mail",
|
"label": "mail",
|
||||||
"port": 7443,
|
"endpoint": "web-tls",
|
||||||
"scheme": "https",
|
"scheme": "https",
|
||||||
"insecure": true
|
"insecure": true
|
||||||
},
|
},
|
||||||
"acme": {
|
"acme": {
|
||||||
"label": "mail",
|
"label": "mail",
|
||||||
"path": "/.well-known/acme-challenge",
|
"path": "/.well-known/acme-challenge",
|
||||||
"port": 7080,
|
"endpoint": "web",
|
||||||
"priority": 100
|
"priority": 100
|
||||||
},
|
},
|
||||||
"autoconfig": {
|
"autoconfig": {
|
||||||
"label": "autoconfig",
|
"label": "autoconfig",
|
||||||
"port": 4243
|
"endpoint": "autoconfig"
|
||||||
},
|
},
|
||||||
"autodiscover": {
|
"autodiscover": {
|
||||||
"label": "autodiscover",
|
"label": "autodiscover",
|
||||||
"port": 4243
|
"endpoint": "autoconfig"
|
||||||
},
|
},
|
||||||
"automx": {
|
"automx": {
|
||||||
"label": "automx",
|
"label": "automx",
|
||||||
"port": 4243
|
"endpoint": "autoconfig"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "smtp",
|
||||||
"port": 25,
|
"port": 25,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "pop3",
|
||||||
"port": 110,
|
"port": 110,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "imap",
|
||||||
"port": 143,
|
"port": 143,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -81,6 +84,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "smtps",
|
||||||
"port": 465,
|
"port": 465,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -88,6 +92,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "submission",
|
||||||
"port": 587,
|
"port": 587,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -95,6 +100,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "imaps",
|
||||||
"port": 993,
|
"port": 993,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -102,6 +108,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "pop3s",
|
||||||
"port": 995,
|
"port": 995,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -109,18 +116,21 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 7080,
|
"port": 7080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web-tls",
|
||||||
"port": 7443,
|
"port": 7443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
|
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "autoconfig",
|
||||||
"port": 4243,
|
"port": 4243,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 59125,
|
"port": 59125,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ COPY . .
|
|||||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
||||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
||||||
# was assembled.
|
# was assembled.
|
||||||
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \
|
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
# **A module may need something the base image does not carry.** The base holds what every module
|
# **A module may need something the base image does not carry.** The base holds what every module
|
||||||
@@ -48,3 +48,8 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
|
|||||||
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
|
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
|
||||||
# `on()` has something to subscribe to.
|
# `on()` has something to subscribe to.
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
|
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
|
||||||
|
|
||||||
|
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
|
||||||
|
# here, beside the entrypoints above, because the module knows which of its files prepares its state
|
||||||
|
# and nothing else could: the mesh asks one word and this says what answers it.
|
||||||
|
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
|
|||||||
|
|
||||||
const graph = Graph.fromEnv();
|
const graph = Graph.fromEnv();
|
||||||
|
|
||||||
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which
|
// The schema is not brought up here. The mesh prepares this module's state before it starts this
|
||||||
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the
|
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
|
||||||
// overlay would block the very apply that brings the overlay up.
|
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph
|
||||||
await graph.migrate();
|
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
|
||||||
|
// would block the very apply that brings the overlay up — stopped being true when a step's failure
|
||||||
|
// became this module's business and not the machine's (ADR 0136).
|
||||||
|
|
||||||
/** What the builder says when it has built something. */
|
/** What the builder says when it has built something. */
|
||||||
interface Built {
|
interface Built {
|
||||||
@@ -47,7 +49,15 @@ interface Built {
|
|||||||
replay?: boolean;
|
replay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
await on("mesh-build-machine.built", async (event) => {
|
/**
|
||||||
|
* What a build means for the graph, wherever it came from.
|
||||||
|
*
|
||||||
|
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
|
||||||
|
* and the control plane says what it already held when this module asks what it missed
|
||||||
|
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
|
||||||
|
* months ago — see `replay` above.
|
||||||
|
*/
|
||||||
|
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||||
const body = event.body as Built;
|
const body = event.body as Built;
|
||||||
if (!body.module || !body.commit) {
|
if (!body.module || !body.commit) {
|
||||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
// Said rather than dropped: a build that announced itself without saying what it built is a
|
||||||
@@ -89,7 +99,11 @@ await on("mesh-build-machine.built", async (event) => {
|
|||||||
because: next.because,
|
because: next.because,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// As it happens, and what the mesh already held when this module asked what it missed.
|
||||||
|
await on("mesh-build-machine.built", placeTheBuild);
|
||||||
|
await on("mesh-controller.built-before", placeTheBuild);
|
||||||
|
|
||||||
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -29,13 +29,16 @@
|
|||||||
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
||||||
},
|
},
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-build-machine.built"
|
"mesh-build-machine.built",
|
||||||
|
"mesh-controller.built-before"
|
||||||
],
|
],
|
||||||
"emits": [
|
"emits": [
|
||||||
"registered",
|
"registered",
|
||||||
"upgraded",
|
"upgraded",
|
||||||
"rebuild-needed"
|
"rebuild-needed",
|
||||||
|
"catching-up"
|
||||||
],
|
],
|
||||||
|
"prepares": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// **The mesh runs this before the version that needs it, and does not start that version if it
|
||||||
|
// fails** — and the refusal reaches this module and nothing else on the machine
|
||||||
|
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
|
||||||
|
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
|
||||||
|
// nothing anywhere said so.
|
||||||
|
//
|
||||||
|
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
|
||||||
|
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
|
||||||
|
// process exiting non-zero is how the host knows not to start the runtime.
|
||||||
|
import { Graph } from "../store.js";
|
||||||
|
|
||||||
|
const graph = Graph.fromEnv();
|
||||||
|
await graph.migrate();
|
||||||
|
console.log("[mesh-catalog] the module graph's schema is what this version needs");
|
||||||
|
await graph.close();
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"pg.d.ts",
|
"pg.d.ts",
|
||||||
"store.ts",
|
"store.ts",
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"tools/index.ts"
|
"tools/index.ts",
|
||||||
|
"prepare/index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"api": {
|
"api": {
|
||||||
"label": "files-api",
|
"label": "files-api",
|
||||||
"port": 9000
|
"endpoint": "s3"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"label": "files",
|
"label": "files",
|
||||||
"port": 9001
|
"endpoint": "console"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -31,12 +31,14 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "s3",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the S3 endpoint"
|
"why": "the S3 endpoint"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "console",
|
||||||
"port": 9001,
|
"port": 9001,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "database",
|
||||||
"port": 27017,
|
"port": 27017,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -34,12 +34,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "mqtt",
|
||||||
"port": 1883,
|
"port": 1883,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "modules on any machine that were granted a topic namespace"
|
"why": "modules on any machine that were granted a topic namespace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "mqtt-websockets",
|
||||||
"port": 8081,
|
"port": 8081,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "database",
|
||||||
"port": 4848,
|
"port": 4848,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "n8n",
|
"label": "n8n",
|
||||||
"port": 5682
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 5682,
|
"port": 5682,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"consumes": [],
|
"consumes": [],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "bus",
|
||||||
"port": 4222,
|
"port": 4222,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"id": "server-conf",
|
"id": "server-conf",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/nats-module/conf/nats.conf",
|
"path": "/var/lib/nats-module/conf/nats.conf",
|
||||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\n# The mesh's own broker certificate \u2014 the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||||
"mode": "0644"
|
"mode": "0644"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,14 +62,14 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-broker-nats:/data",
|
"/var/lib/mesh-broker-nats:/data",
|
||||||
"/var/lib/nats-module/conf:/etc/nats:ro",
|
"/var/lib/nats-module/conf:/etc/nats:ro",
|
||||||
"/var/lib/mesh-broker-nats-tls:/tls:ro"
|
"/var/lib/mesh-broker-tls:/tls:ro"
|
||||||
],
|
],
|
||||||
"artifact": "server"
|
"artifact": "server"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"accesses": [
|
"accesses": [
|
||||||
{
|
{
|
||||||
"path": "/var/lib/mesh-broker-nats-tls",
|
"path": "/var/lib/mesh-broker-tls",
|
||||||
"mode": "read"
|
"mode": "read"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "drive",
|
"label": "drive",
|
||||||
"port": 80
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 1880,
|
"port": 1880,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "nodered",
|
"label": "nodered",
|
||||||
"port": 1880
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "@",
|
"label": "@",
|
||||||
"port": 4000
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4000,
|
"port": 4000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 6789,
|
"port": 6789,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
|
"MESH_NZBGET_URL": "http://127.0.0.1:${port:6789}",
|
||||||
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
|
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
|
||||||
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
|
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
|
||||||
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
|
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 11434,
|
"port": 11434,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "machine",
|
"from": "machine",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
|||||||
FROM ${BUILD_BASE} AS build
|
FROM ${BUILD_BASE} AS build
|
||||||
WORKDIR /app/modules/ombi
|
WORKDIR /app/modules/ombi
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
FROM ${RUNTIME_BASE}
|
||||||
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/ombi/dist /app/modules/ombi/dist
|
|||||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||||
# the convention novox/hq issues 060/061 settled.
|
# the convention novox/hq issues 060/061 settled.
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js
|
ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js
|
||||||
|
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
|
||||||
|
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
|
||||||
|
# serving sidecar too, and exit it.
|
||||||
|
|||||||
+60
-11
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3579,
|
"port": 3579,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -27,10 +28,15 @@
|
|||||||
"path": "/var/lib/mesh/ombi",
|
"path": "/var/lib/mesh/ombi",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"place": "."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "config",
|
"id": "config",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/services/ombi/config",
|
|
||||||
"mode": "0700",
|
"mode": "0700",
|
||||||
"owner": "1000:1000"
|
"owner": "1000:1000"
|
||||||
},
|
},
|
||||||
@@ -38,7 +44,7 @@
|
|||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "ombi",
|
"name": "ombi",
|
||||||
"image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d",
|
"image": "lscr.io/linuxserver/ombi@sha256:22d6ebadbaaa728571353e74dc2173719e0fb02d4eaec551a7e9d2ee99ef68ac",
|
||||||
"env": {
|
"env": {
|
||||||
"PUID": "1000",
|
"PUID": "1000",
|
||||||
"PGID": "1000",
|
"PGID": "1000",
|
||||||
@@ -48,7 +54,7 @@
|
|||||||
"3579"
|
"3579"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/ombi/config:/config"
|
"${dir:config}:/config"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,33 +73,76 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
|
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
|
||||||
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
|
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro"
|
||||||
"/services/ombi/config:/var/lib/ombi/config:ro"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_OMBI_URL": "http://127.0.0.1:3579",
|
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
|
||||||
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
|
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
|
||||||
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
|
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json"
|
||||||
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
|
|
||||||
},
|
},
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"runtime-config"
|
"runtime-config"
|
||||||
],
|
],
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "servarr",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mesh-ombi-servarr",
|
||||||
|
"network": "host",
|
||||||
|
"run-once": true,
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
|
||||||
|
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
|
||||||
|
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
|
||||||
|
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
|
||||||
|
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro",
|
||||||
|
"${dir:state}/lidarr-api.json:/run/servarr/lidarr-api.json:ro",
|
||||||
|
"${dir:state}/lidarr-api.secret:/run/servarr/lidarr-api.secret:ro"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
|
||||||
|
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
|
||||||
|
"MESH_SERVARR_DIR": "/run/servarr"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"/app/modules/ombi/dist/servarr/index.js"
|
||||||
|
],
|
||||||
|
"restart-on": [
|
||||||
|
"bound-sonarr-api",
|
||||||
|
"secret-sonarr-api",
|
||||||
|
"bound-radarr-api",
|
||||||
|
"secret-radarr-api",
|
||||||
|
"bound-lidarr-api",
|
||||||
|
"secret-lidarr-api"
|
||||||
|
],
|
||||||
|
"artifact": "runtime"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requires": [
|
"requires": [
|
||||||
"route"
|
"lidarr-api",
|
||||||
|
"radarr-api",
|
||||||
|
"route",
|
||||||
|
"sonarr-api"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "ombi",
|
"label": "ombi",
|
||||||
"port": 3579
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
"route": "/var/lib/mesh/ombi/route.json"
|
"route": "${dir:state}/route.json",
|
||||||
|
"sonarr-api": "${dir:state}/sonarr-api.json",
|
||||||
|
"radarr-api": "${dir:state}/radarr-api.json",
|
||||||
|
"lidarr-api": "${dir:state}/lidarr-api.json"
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"sonarr-api": "${dir:state}/sonarr-api.secret",
|
||||||
|
"radarr-api": "${dir:state}/radarr-api.secret",
|
||||||
|
"lidarr-api": "${dir:state}/lidarr-api.secret"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
"on": [
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
"description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0039).",
|
"description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0039).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// ombi's Servarr step — run once by the host after ombi'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**, for the reason route-adapter gives: 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, an
|
||||||
|
// ombi that cannot reach it — so the node reports the step failed and the host runs it again on the
|
||||||
|
// next apply. It is declared last in the manifest, so its failing gates nothing else of ombi'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. Never prints a key.
|
||||||
|
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
|
||||||
|
|
||||||
|
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
|
||||||
|
const url = process.env.MESH_OMBI_URL ?? "http://127.0.0.1:3579";
|
||||||
|
const apiKey = (await readIfThere(process.env.MESH_OMBI_API_KEY_FILE))?.trim() ?? process.env.MESH_OMBI_API_KEY ?? "";
|
||||||
|
const waitSeconds = Number(process.env.MESH_OMBI_WAIT_SECONDS ?? "180");
|
||||||
|
|
||||||
|
const http: Http = { fetch: (u, init) => fetch(u, init) };
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error("[ombi-servarr] no ombi API key — ombi's own `api-key` secret has not been accepted");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const ombi = { url, apiKey };
|
||||||
|
|
||||||
|
if (!(await ombiReady(http, ombi, waitSeconds * 1000))) {
|
||||||
|
console.error(`[ombi-servarr] ombi did not answer at ${url} within ${waitSeconds}s`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
for (const spec of APPS) {
|
||||||
|
const outcome = await reconcileApp(
|
||||||
|
http,
|
||||||
|
ombi,
|
||||||
|
spec,
|
||||||
|
await readBinding(join(dir, `${spec.provision}.json`)),
|
||||||
|
await readIfThere(join(dir, `${spec.provision}.secret`)),
|
||||||
|
);
|
||||||
|
switch (outcome.result) {
|
||||||
|
case "unchanged":
|
||||||
|
console.log(`[ombi-servarr] ${outcome.app}: already as the mesh says; connection tested`);
|
||||||
|
break;
|
||||||
|
case "written":
|
||||||
|
console.log(`[ombi-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`);
|
||||||
|
break;
|
||||||
|
case "refused":
|
||||||
|
failed++;
|
||||||
|
console.error(`[ombi-servarr] ${outcome.app}: ${outcome.problem}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exitCode = failed > 0 ? 1 : 0;
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
"include": ["client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "office",
|
"label": "office",
|
||||||
"port": 9070
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 9070,
|
"port": 9070,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "eef",
|
"label": "eef",
|
||||||
"port": 4012
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4012,
|
"port": 4012,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "filip",
|
"label": "filip",
|
||||||
"port": 4013
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4013,
|
"port": 4013,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "photos",
|
"label": "photos",
|
||||||
"port": 4001
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -32,12 +32,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the photos backend API; the client sites on the module network call it"
|
"why": "the photos backend API; the client sites on the module network call it"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4001,
|
"port": 4001,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "stream",
|
||||||
"port": 32400,
|
"port": 32400,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 9090,
|
"port": 9090,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web-tls",
|
||||||
"port": 9443,
|
"port": 9443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "portainer",
|
"label": "portainer",
|
||||||
"port": 9090
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "database",
|
||||||
"port": 5432,
|
"port": 5432,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
|
"MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8080}",
|
||||||
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
|
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
|
||||||
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
|
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
|
||||||
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
|
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
{
|
{
|
||||||
"module": "radarr",
|
"module": "radarr",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "radarr-api",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serves": {
|
||||||
|
"radarr-api": {
|
||||||
|
"scheme": "http",
|
||||||
|
"port": 7878,
|
||||||
|
"url-base": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
@@ -14,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 7878,
|
"port": 7878,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -74,7 +88,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_RADARR_URL": "http://127.0.0.1:7878",
|
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
|
||||||
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
|
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
|
||||||
},
|
},
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
@@ -86,7 +100,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "movies",
|
"label": "movies",
|
||||||
"port": 7878
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "cache",
|
||||||
"port": 6379,
|
"port": 6379,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -27,12 +27,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "http",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
|
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "https",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
|
|||||||
+24
-22
@@ -5,11 +5,12 @@
|
|||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"secret": "/var/lib/searxng-module/secret.secret",
|
"secret": "/var/lib/mesh/searxng/secret",
|
||||||
"broker": "/var/lib/mesh/searxng/broker"
|
"broker": "/var/lib/mesh/searxng/broker"
|
||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -26,22 +27,14 @@
|
|||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/var/lib/searxng-module",
|
"mode": "0700",
|
||||||
"mode": "0700"
|
"place": "."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "valkey-data",
|
"id": "valkey-data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/var/lib/searxng-module/valkey-data",
|
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "server-env",
|
|
||||||
"type": "file",
|
|
||||||
"path": "/var/lib/searxng-module/server.env",
|
|
||||||
"mode": "0600",
|
|
||||||
"content": "SEARXNG_SECRET=${secret:secret}\nSEARXNG_VALKEY_URL=valkey://valkey:6379/0\n"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
"type": "network",
|
"type": "network",
|
||||||
@@ -62,30 +55,39 @@
|
|||||||
"warning"
|
"warning"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/searxng-module/valkey-data:/data"
|
"${dir:valkey-data}:/data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "settings",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/settings.yml",
|
||||||
|
"mode": "0600",
|
||||||
|
"merge": "json",
|
||||||
|
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "searxng",
|
"name": "searxng",
|
||||||
"image": "searxng/searxng@sha256:c7cc75852051bf6254afda6ed1b920dd1677d8efe4ab141bf558f02e582f4371",
|
"image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441",
|
||||||
"network": "searxng",
|
"network": "searxng",
|
||||||
"env-file": [
|
|
||||||
"/var/lib/searxng-module/server.env"
|
|
||||||
],
|
|
||||||
"ports": [
|
"ports": [
|
||||||
"8080"
|
"8080"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "SEARXNG_SECRET is env-only, but settings.yml carries server.secret_key; convertible by mounting a generated settings.yml, not yet done"
|
"volumes": [
|
||||||
|
"${dir:state}/settings.yml:/etc/searxng/settings.yml:ro"
|
||||||
|
],
|
||||||
|
"restart-on": [
|
||||||
|
"settings"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime-config",
|
"id": "runtime-config",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mesh/searxng/config.json",
|
"path": "/var/lib/mesh/searxng/config.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "{}\n",
|
"content": "{}\n"
|
||||||
"merge": "json"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "runtime",
|
||||||
@@ -98,7 +100,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_SEARXNG_URL": "http://127.0.0.1:8080",
|
"MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
|
||||||
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
|
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
|
||||||
},
|
},
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
@@ -113,11 +115,11 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "searxng",
|
"label": "searxng",
|
||||||
"port": 8080
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
"route": "/var/lib/searxng-module/route.json"
|
"route": "${dir:state}/route.json"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
"on": [
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
{
|
{
|
||||||
"module": "sonarr",
|
"module": "sonarr",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "sonarr-api",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serves": {
|
||||||
|
"sonarr-api": {
|
||||||
|
"scheme": "http",
|
||||||
|
"port": 8989,
|
||||||
|
"url-base": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
@@ -14,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8989,
|
"port": 8989,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -79,7 +93,7 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_SONARR_URL": "http://127.0.0.1:8989",
|
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
|
||||||
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
|
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
|
||||||
},
|
},
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
@@ -91,7 +105,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "series",
|
"label": "series",
|
||||||
"port": 8989
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "ssh",
|
||||||
"port": 22,
|
"port": 22,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"type": "service",
|
"type": "service",
|
||||||
"unit": "sshd.service",
|
"unit": "sshd.service",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
|
"boot": "enabled",
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"config"
|
"config"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "acme",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8181,
|
"port": 8181,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "tautulli",
|
"label": "tautulli",
|
||||||
"port": 8181
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "umami",
|
"label": "umami",
|
||||||
"port": 3000
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -49,10 +49,11 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "mesh",
|
||||||
"why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to \u2014 so the port itself must be reachable from anywhere"
|
"why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
|
|||||||
@@ -6,54 +6,63 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "inform",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "stun",
|
||||||
"port": 3478,
|
"port": 3478,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "STUN, so managed devices can find the controller through NAT"
|
"why": "STUN, so managed devices can find the controller through NAT"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "discovery",
|
||||||
"port": 10001,
|
"port": 10001,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "discovery-l2",
|
||||||
"port": 1902,
|
"port": 1902,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "portal-tls",
|
||||||
"port": 8843,
|
"port": 8843,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the guest captive portal over https"
|
"why": "the guest captive portal over https"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "portal",
|
||||||
"port": 8880,
|
"port": 8880,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the guest captive portal over http"
|
"why": "the guest captive portal over http"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "speedtest",
|
||||||
"port": 6789,
|
"port": 6789,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "mobile-app speed-test throughput measurement"
|
"why": "mobile-app speed-test throughput measurement"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "syslog",
|
||||||
"port": 5514,
|
"port": 5514,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
Reference in New Issue
Block a user