diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts index b84f6c5..9d6acce 100644 --- a/modules/bazarr/client.ts +++ b/modules/bazarr/client.ts @@ -3,6 +3,8 @@ // missing subtitles, searches providers for them, and records what it downloaded. This client // talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it. +import { readFileSync } from "node:fs"; + export interface WantedSubtitle { kind: "episode" | "movie"; title: string; // series + episode, or movie title @@ -34,6 +36,13 @@ export interface HistoryEntry { description?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class BazarrClient { readonly baseUrl: string; @@ -47,8 +56,9 @@ export class BazarrClient { /** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key * there is nothing to talk to, so this throws rather than run half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { - const url = env.MESH_BAZARR_URL; - const apiKey = env.MESH_BAZARR_API_KEY; + const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE); + const url = cfg.url ?? env.MESH_BAZARR_URL; + const apiKey = cfg.apiKey ?? env.MESH_BAZARR_API_KEY; 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"); return new BazarrClient(url, apiKey); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index b90dd88..d5018a2 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -80,6 +80,24 @@ "/services/media/anime:/anime", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-bazarr", + "image": "mesh-runtime-bazarr@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro", + "/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro", + "/services/bazarr/config:/var/lib/bazarr/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_BAZARR_URL": "http://127.0.0.1:6767", + "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", + "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" + } } ] } diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index c17b84a..5fe7182 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -3,6 +3,8 @@ // gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea // does. +import { readFileSync } from "node:fs"; + /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { full_name: string; @@ -42,6 +44,13 @@ export interface GiteaLabel { name: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class GiteaClient { readonly baseUrl: string; private cachedUsername: string | null = null; @@ -60,8 +69,9 @@ export class GiteaClient { * call to make, so this throws rather than hand back a client that fails on first use. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { - const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; - const token = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; + const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); return new GiteaClient(url, token); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index b6af10e..b8ec92d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -88,6 +88,30 @@ "volumes": [ "/services/gitea/gitea:/data" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/gitea/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-gitea", + "image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", + "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_GITEA_URL": "http://127.0.0.1:3000", + "MESH_GITEA_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/grafana/client.ts b/modules/grafana/client.ts index 41395ae..00916c3 100644 --- a/modules/grafana/client.ts +++ b/modules/grafana/client.ts @@ -2,6 +2,8 @@ // the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both // this module's tools and its events entrypoint import it, and nothing outside grafana does. +import { readFileSync } from "node:fs"; + export interface GrafanaHealth { database: string; version: string; @@ -35,6 +37,13 @@ export interface GrafanaAlert { activeAt?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class GrafanaClient { readonly baseUrl: string; private readonly authHeader: string; @@ -51,12 +60,13 @@ export class GrafanaClient { * Throws when neither is configured — the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { - const url = env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; - const token = env.MESH_GRAFANA_TOKEN; + const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE); + const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; + const token = cfg.token ?? env.MESH_GRAFANA_TOKEN; if (token) return new GrafanaClient(url, `Bearer ${token}`); - const password = env.MESH_GRAFANA_PASSWORD; + const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD; if (password) { - const user = env.MESH_GRAFANA_USER ?? "admin"; + const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin"; return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); } throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 3835b69..5d9626e 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -60,6 +60,30 @@ "volumes": [ "/services/grafana/data:/var/lib/grafana" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/grafana/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-grafana", + "image": "mesh-runtime-grafana@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/grafana/broker:/run/secrets/broker:ro", + "/var/lib/mesh/grafana/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_GRAFANA_URL": "http://127.0.0.1:3000", + "MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts index d7aefa9..d3900ba 100644 --- a/modules/home-assistant/client.ts +++ b/modules/home-assistant/client.ts @@ -2,6 +2,8 @@ // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // home-assistant does. Talks to the HA REST API (/api) with a long-lived access token. +import { readFileSync } from "node:fs"; + export interface HAEntityState { entity_id: string; state: string; @@ -18,6 +20,13 @@ export interface HAConfig { state?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class HomeAssistantClient { readonly baseUrl: string; @@ -34,8 +43,9 @@ export class HomeAssistantClient { * every API call is Bearer-authenticated and there is nowhere to discover it from. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { - const url = env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; - const token = env.MESH_HOMEASSISTANT_TOKEN; + const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; + const token = cfg.token ?? env.MESH_HOMEASSISTANT_TOKEN; if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN"); return new HomeAssistantClient(url, token); } diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 3e63d64..d6cee10 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -44,6 +44,24 @@ "volumes": [ "/services/home-assistant/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-home-assistant", + "image": "mesh-runtime-home-assistant@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro", + "/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro", + "/services/home-assistant/config:/var/lib/home-assistant/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123", + "MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json", + "MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config" + } } ] } diff --git a/modules/icecast/client.ts b/modules/icecast/client.ts index d074b55..7035c3d 100644 --- a/modules/icecast/client.ts +++ b/modules/icecast/client.ts @@ -3,6 +3,8 @@ // endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and // the basis for both the status tool and the stream started/stopped events. +import { readFileSync } from "node:fs"; + export interface IcecastMount { /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ mount: string; @@ -33,6 +35,13 @@ interface RawSource { server_type?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class IcecastClient { readonly baseUrl: string; private readonly authHeader?: string; @@ -46,8 +55,9 @@ export class IcecastClient { } static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { - const url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`; - return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, env.MESH_ICECAST_ADMIN_PASSWORD); + const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`); + return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD); } async getStatus(): Promise { diff --git a/modules/icecast/module.json b/modules/icecast/module.json index b60778d..5a703eb 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -53,6 +53,30 @@ "ports": [ "8000" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/icecast/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-icecast", + "image": "mesh-runtime-icecast@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/icecast/broker:/run/secrets/broker:ro", + "/var/lib/mesh/icecast/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_ICECAST_URL": "http://127.0.0.1:8000", + "MESH_ICECAST_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts index 12a81eb..ab0fc73 100644 --- a/modules/influxdb/client.ts +++ b/modules/influxdb/client.ts @@ -1,6 +1,8 @@ // The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only // this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token. +import { readFileSync } from "node:fs"; + export interface InfluxHealth { name?: string; status?: string; @@ -15,6 +17,13 @@ export interface InfluxBucket { retentionSeconds?: number; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class InfluxDBClient { readonly baseUrl: string; @@ -32,10 +41,11 @@ export class InfluxDBClient { * is token-authenticated. The org scopes bucket listing and queries. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient { - const url = env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; - const token = env.MESH_INFLUXDB_TOKEN; + const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE); + const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; + const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN; if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN"); - const org = env.MESH_INFLUXDB_ORG ?? "mesh"; + const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh"; return new InfluxDBClient(url, token, org); } diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index ad64e0c..bf95774 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -6,7 +6,8 @@ ], "own-secrets": { "admin": "/var/lib/influxdb-module/admin.secret", - "admin-token": "/var/lib/influxdb-module/admin-token.secret" + "admin-token": "/var/lib/influxdb-module/admin-token.secret", + "broker": "/var/lib/mesh/influxdb/broker" }, "listens": [ { @@ -17,6 +18,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/influxdb", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -59,6 +66,24 @@ "/services/influxdb/data:/var/lib/influxdb2", "/services/influxdb/config:/etc/influxdb2" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-influxdb", + "image": "mesh-runtime-influxdb@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro", + "/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro", + "/services/influxdb/config:/var/lib/influxdb/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_INFLUXDB_URL": "http://127.0.0.1:8086", + "MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json", + "MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config" + } } ] } diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts index bf52da9..1e76be3 100644 --- a/modules/jackett/client.ts +++ b/modules/jackett/client.ts @@ -2,6 +2,8 @@ // an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client // talks its /api/v2.0 REST API, and only jackett's tools import it. +import { readFileSync } from "node:fs"; + export interface JackettIndexer { id: string; name: string; @@ -22,6 +24,13 @@ export interface JackettResult { link?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class JackettClient { readonly baseUrl: string; @@ -38,8 +47,9 @@ export class JackettClient { * module contributes no tools rather than failing half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { - const url = env.MESH_JACKETT_URL; - const apiKey = env.MESH_JACKETT_API_KEY; + const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_JACKETT_URL; + const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY; if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY"); return new JackettClient(url, apiKey); diff --git a/modules/jackett/module.json b/modules/jackett/module.json index f1e4a74..6cbb4a0 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/jackett", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -36,6 +42,27 @@ "volumes": [ "/services/jackett/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-jackett", + "image": "mesh-runtime-jackett@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/jackett/broker:/run/secrets/broker:ro", + "/var/lib/mesh/jackett/config.json:/run/config/config.json:ro", + "/services/jackett/config:/var/lib/jackett/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_JACKETT_URL": "http://127.0.0.1:9117", + "MESH_JACKETT_CONFIG_FILE": "/run/config/config.json", + "MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config" + } } - ] + ], + "own-secrets": { + "broker": "/var/lib/mesh/jackett/broker" + } } diff --git a/modules/keycloak/client.ts b/modules/keycloak/client.ts index 431d331..830418c 100644 --- a/modules/keycloak/client.ts +++ b/modules/keycloak/client.ts @@ -3,6 +3,15 @@ // it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and // nothing outside keycloak does. +import { readFileSync } from "node:fs"; + +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class KeycloakClient { readonly baseUrl: string; readonly defaultRealm: string; @@ -28,11 +37,12 @@ export class KeycloakClient { * here lets the tool runtime expose no keycloak tools rather than tools that always error. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { - const url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; - const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; - const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; + const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE); + const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; + const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; + const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD"); - const realm = env.MESH_KEYCLOAK_REALM ?? "master"; + const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master"; return new KeycloakClient(url, adminUser, adminPass, realm); } diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 0bc6c51..7314428 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -91,6 +91,30 @@ "ports": [ "8080" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/keycloak/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-keycloak", + "image": "mesh-runtime-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro", + "/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_KEYCLOAK_URL": "http://127.0.0.1:8080", + "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index fef227f..32053cb 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -9,6 +9,7 @@ // falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve. import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; import { promisify } from "node:util"; const run = promisify(execFile); @@ -44,6 +45,13 @@ export interface MailMessage { // The fields we ask doveadm for, once — kept together so read and search stay identical in shape. const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet"; +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class MailuClient { readonly baseUrl: string; @@ -62,12 +70,13 @@ export class MailuClient { * client with neither would only fail later, one call at a time, so it fails here instead. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient { - const url = env.MESH_MAILU_URL; - const apiKey = env.MESH_MAILU_API_KEY; + const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE); + const url = cfg.url ?? env.MESH_MAILU_URL; + const apiKey = cfg.apiKey ?? env.MESH_MAILU_API_KEY; if (!url || !apiKey) { throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY"); } - const imapContainer = env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; + const imapContainer = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; return new MailuClient(url, apiKey, imapContainer); } diff --git a/modules/mailu/module.json b/modules/mailu/module.json index bf64c8a..c8e902e 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -282,6 +282,31 @@ "/services/mailu/data/certs:/certs", "/services/mailu/data/overrides/nginx:/overrides:ro" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/mailu/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-mailu", + "image": "mesh-runtime-mailu@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", + "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", + "/var/run/docker.sock:/var/run/docker.sock" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_MAILU_URL": "http://127.0.0.1:80", + "MESH_MAILU_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index 5418924..b860680 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -9,6 +9,7 @@ // because occ has no version-stable "list every share" across the releases we run. import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; export interface NextcloudUser { uid: string; @@ -24,6 +25,13 @@ export interface NextcloudShare { owner: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class NextcloudClient { constructor( private readonly container: string, @@ -40,10 +48,11 @@ export class NextcloudClient { * throws without it, and the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { - const container = env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; - const ocsUrl = env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; - const adminUser = env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; - const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD; + const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); + const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; + const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; + const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD; if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD"); return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); } diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index a84d447..7653607 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -81,6 +81,31 @@ "volumes": [ "/services/nextcloud/html:/var/www/html" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/nextcloud/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nextcloud", + "image": "mesh-runtime-nextcloud@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro", + "/var/run/docker.sock:/var/run/docker.sock" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NEXTCLOUD_URL": "http://127.0.0.1:80", + "MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/nodered/client.ts b/modules/nodered/client.ts index 7b92f59..d760ca6 100644 --- a/modules/nodered/client.ts +++ b/modules/nodered/client.ts @@ -5,6 +5,8 @@ // configuration, GET /nodes for installed node modules. A default install has no auth; when // adminAuth is on, a bearer token (minted at /auth/token) is required. +import { readFileSync } from "node:fs"; + export interface NodeRedFlow { /** The tab (flow) node id. */ id: string; @@ -18,6 +20,13 @@ export interface NodeRedNodeModule { types: string[]; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class NodeRedClient { readonly baseUrl: string; @@ -35,9 +44,10 @@ export class NodeRedClient { * a default install needs none. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient { - const url = env.MESH_NODERED_URL; + const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE); + const url = cfg.url ?? env.MESH_NODERED_URL; if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL"); - return new NodeRedClient(url, env.MESH_NODERED_TOKEN); + return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN); } private headers(extra: Record = {}): Record { diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 9a48039..b9d84b3 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -46,6 +46,30 @@ "volumes": [ "/services/nodered/data:/data" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/nodered/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nodered", + "image": "mesh-runtime-nodered@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nodered/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nodered/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NODERED_URL": "http://127.0.0.1:1880", + "MESH_NODERED_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts index 668a586..e217387 100644 --- a/modules/nzbget/client.ts +++ b/modules/nzbget/client.ts @@ -3,6 +3,8 @@ // nzbget and nothing else. Both this module's tools and its events entrypoint import it, and // nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. +import { readFileSync } from "node:fs"; + export interface NzbgetStatus { /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ speedBytesPerSec: number; @@ -39,6 +41,13 @@ export interface NzbgetHistoryItem { success: boolean; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class NzbgetClient { readonly rpcUrl: string; private readonly auth: string; @@ -55,12 +64,13 @@ export class NzbgetClient { * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { - const url = env.MESH_NZBGET_URL; - const password = env.MESH_NZBGET_PASSWORD; + const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); + const url = cfg.url ?? env.MESH_NZBGET_URL; + const password = cfg.password ?? env.MESH_NZBGET_PASSWORD; if (!url || !password) { throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); } - const user = env.MESH_NZBGET_USER ?? "nzbget"; + const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget"; return new NzbgetClient(url, user, password); } diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 184a1b3..239f8a8 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -58,6 +58,24 @@ "/services/nzbget/config:/config", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-nzbget", + "image": "mesh-runtime-nzbget@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nzbget/config.json:/run/config/config.json:ro", + "/services/nzbget/config:/var/lib/nzbget/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_NZBGET_URL": "http://127.0.0.1:6789", + "MESH_NZBGET_CONFIG_FILE": "/run/config/config.json", + "MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config" + } } ] } diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts index 870e37e..8016ccb 100644 --- a/modules/ombi/client.ts +++ b/modules/ombi/client.ts @@ -2,6 +2,8 @@ // request front-end: viewers ask for movies and shows, and an operator approves them. This client // talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. +import { readFileSync } from "node:fs"; + export interface OmbiRequest { kind: "movie" | "tv"; id: number; @@ -20,6 +22,13 @@ export interface RequestCounts { available: number; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class OmbiClient { readonly baseUrl: string; @@ -33,8 +42,9 @@ export class OmbiClient { /** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there * is nothing to talk to, so this throws rather than run half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { - const url = env.MESH_OMBI_URL; - const apiKey = env.MESH_OMBI_API_KEY; + const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE); + const url = cfg.url ?? env.MESH_OMBI_URL; + const apiKey = cfg.apiKey ?? env.MESH_OMBI_API_KEY; if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); return new OmbiClient(url, apiKey); diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 8a7a5cb..6e29b26 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -49,6 +49,24 @@ "volumes": [ "/services/ombi/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-ombi", + "image": "mesh-runtime-ombi@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/ombi/broker:/run/secrets/broker:ro", + "/var/lib/mesh/ombi/config.json:/run/config/config.json:ro", + "/services/ombi/config:/var/lib/ombi/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_OMBI_URL": "http://127.0.0.1:3579", + "MESH_OMBI_CONFIG_FILE": "/run/config/config.json", + "MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config" + } } ] } diff --git a/modules/photos/client.ts b/modules/photos/client.ts index d0a8b66..2305b1f 100644 --- a/modules/photos/client.ts +++ b/modules/photos/client.ts @@ -3,6 +3,8 @@ // by an API key sent as the `x-api-key` header). The client speaks only what the tools and the // item-added event need: server version and statistics, albums, and recent assets. +import { readFileSync } from "node:fs"; + export interface PhotosServerInfo { version: string; photos?: number; @@ -24,6 +26,13 @@ export interface PhotosAsset { createdAt?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class PhotosClient { readonly baseUrl: string; @@ -38,8 +47,9 @@ export class PhotosClient { * the API key is required, and without it the module contributes nothing rather than reaching an * unauthenticated endpoint. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { - const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; - const key = env.MESH_PHOTOS_API_KEY; + const cfg = meshConfig(env.MESH_PHOTOS_CONFIG_FILE); + const url = cfg.url ?? env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; + const key = cfg.apiKey ?? env.MESH_PHOTOS_API_KEY; if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); return new PhotosClient(url, key); } diff --git a/modules/photos/module.json b/modules/photos/module.json index e24f528..555a044 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -47,6 +47,25 @@ "/etc/photos/store.json:/etc/photos/store.json:ro", "/etc/photos/store.secret:/etc/photos/store.secret:ro" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-photos", + "image": "mesh-runtime-photos@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/photos/broker:/run/secrets/broker:ro", + "/var/lib/mesh/photos/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PHOTOS_URL": "http://127.0.0.1:2283", + "MESH_PHOTOS_CONFIG_FILE": "/run/config/config.json" + } } + ], + "capabilities": [ + "container-runtime" ] } diff --git a/modules/portainer/client.ts b/modules/portainer/client.ts index 79eaa29..a7ac3d1 100644 --- a/modules/portainer/client.ts +++ b/modules/portainer/client.ts @@ -3,6 +3,8 @@ // which the host owns and emits — so this module reads Portainer's own resources (endpoints, // stacks, containers) and exposes them, and stops there. +import { readFileSync } from "node:fs"; + export interface PortainerEndpoint { id: number; name: string; @@ -27,6 +29,13 @@ export interface PortainerContainer { status: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class PortainerClient { readonly baseUrl: string; @@ -44,8 +53,9 @@ export class PortainerClient { * exposes nothing rather than calling Portainer unauthenticated. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { - const url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; - const token = env.MESH_PORTAINER_TOKEN; + const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE); + const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; + const token = cfg.token ?? env.MESH_PORTAINER_TOKEN; if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN"); return new PortainerClient(url, token); } diff --git a/modules/portainer/module.json b/modules/portainer/module.json index 0f0da7a..db02e54 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/portainer", + "mode": "0700" + }, { "id": "data", "type": "directory", @@ -31,6 +37,33 @@ "/services/portainer/data:/data", "/var/run/docker.sock:/var/run/docker.sock" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/portainer/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-portainer", + "image": "mesh-runtime-portainer@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/portainer/broker:/run/secrets/broker:ro", + "/var/lib/mesh/portainer/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_PORTAINER_URL": "https://127.0.0.1:9443", + "MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json" + } } - ] + ], + "own-secrets": { + "broker": "/var/lib/mesh/portainer/broker" + } } diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts index 24cc65f..b59c171 100644 --- a/modules/qbittorrent/client.ts +++ b/modules/qbittorrent/client.ts @@ -7,6 +7,8 @@ // against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is // captured on login and carried by hand on every later call, with a single re-login on expiry. +import { readFileSync } from "node:fs"; + export interface QbTransferInfo { dlSpeedBytesPerSec: number; upSpeedBytesPerSec: number; @@ -30,6 +32,13 @@ export interface QbTorrent { savePath: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class QbittorrentClient { readonly baseUrl: string; private sid: string | null = null; @@ -49,12 +58,13 @@ export class QbittorrentClient { * (the harness treats the throw as "exposes nothing"). The user defaults to "admin". */ static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { - const url = env.MESH_QBITTORRENT_URL; - const password = env.MESH_QBITTORRENT_PASSWORD; + const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_QBITTORRENT_URL; + const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD; if (!url || !password) { throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); } - const user = env.MESH_QBITTORRENT_USER ?? "admin"; + const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin"; return new QbittorrentClient(url, user, password); } diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 4cf1739..5cf65a8 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -58,6 +58,24 @@ "/services/qbittorrent/config:/config", "/services/media/downloads:/downloads" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-qbittorrent", + "image": "mesh-runtime-qbittorrent@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro", + "/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro", + "/services/qbittorrent/config:/var/lib/qbittorrent/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", + "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", + "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" + } } ] } diff --git a/modules/searxng/client.ts b/modules/searxng/client.ts index bfeb7a9..8bddb1a 100644 --- a/modules/searxng/client.ts +++ b/modules/searxng/client.ts @@ -3,6 +3,8 @@ // merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool // useful; the client speaks only that. No credential — the instance is reached inside the mesh. +import { readFileSync } from "node:fs"; + export interface SearxResult { title: string; url: string; @@ -26,6 +28,13 @@ export interface SearxOptions { pageno?: number; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class SearxngClient { readonly baseUrl: string; @@ -36,7 +45,8 @@ export class SearxngClient { /** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL * is all it takes — defaulting to the container's own listen port. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient { - const url = env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`; + const cfg = meshConfig(env.MESH_SEARXNG_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`); return new SearxngClient(url); } diff --git a/modules/searxng/module.json b/modules/searxng/module.json index 8262a4b..a19c064 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -5,7 +5,8 @@ "container-runtime" ], "own-secrets": { - "secret": "/var/lib/searxng-module/secret.secret" + "secret": "/var/lib/searxng-module/secret.secret", + "broker": "/var/lib/mesh/searxng/broker" }, "listens": [ { @@ -16,6 +17,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/searxng", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -64,6 +71,30 @@ "ports": [ "8080" ] + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/mesh/searxng/config.json", + "mode": "0600", + "content": "{}\n", + "merge": "json" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-searxng", + "image": "mesh-runtime-searxng@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/searxng/broker:/run/secrets/broker:ro", + "/var/lib/mesh/searxng/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_SEARXNG_URL": "http://127.0.0.1:8080", + "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" + } } ] } diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts index 6c308c6..2096a07 100644 --- a/modules/tautulli/client.ts +++ b/modules/tautulli/client.ts @@ -5,6 +5,8 @@ // { response: { result: "success" | "error", message, data } }. This client unwraps that envelope // and hands back only the data. +import { readFileSync } from "node:fs"; + export interface TautulliSession { user: string; title: string; @@ -32,6 +34,13 @@ export interface TautulliHomeStat { rows: Array>; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class TautulliClient { readonly baseUrl: string; @@ -48,8 +57,9 @@ export class TautulliClient { * Throws when no key is configured — the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { - const url = env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`; - const apiKey = env.MESH_TAUTULLI_APIKEY; + const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`); + const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY; if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY"); return new TautulliClient(url, apiKey); } diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index 27bfc8f..94ad122 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -48,6 +48,24 @@ "volumes": [ "/services/tautulli/config:/config" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-tautulli", + "image": "mesh-runtime-tautulli@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro", + "/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro", + "/services/tautulli/config:/var/lib/tautulli/config:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_TAUTULLI_URL": "http://127.0.0.1:8181", + "MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json", + "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config" + } } ] } diff --git a/modules/verdaccio/client.ts b/modules/verdaccio/client.ts index 9948fdf..0a9acb0 100644 --- a/modules/verdaccio/client.ts +++ b/modules/verdaccio/client.ts @@ -2,6 +2,8 @@ // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // verdaccio does. +import { readFileSync } from "node:fs"; + export interface VerdaccioPackage { name: string; version?: string; @@ -17,6 +19,13 @@ export interface PackageInfo { modified?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class VerdaccioClient { readonly baseUrl: string; @@ -34,9 +43,10 @@ export class VerdaccioClient { * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient { - const url = env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`; + const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE); + const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`); if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL"); - return new VerdaccioClient(url, env.MESH_VERDACCIO_TOKEN); + return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN); } private async getJson(path: string): Promise { diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index f9d93c0..1f35dcf 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -58,6 +58,22 @@ "/services/verdaccio/storage:/verdaccio/storage", "/services/verdaccio/conf:/verdaccio/conf" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-verdaccio", + "image": "mesh-runtime-verdaccio@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro", + "/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_VERDACCIO_URL": "http://127.0.0.1:4873", + "MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json" + } } ] }