audit-logger: the assigned-module manifest (ADR 0048) #2

Merged
jschoubben merged 28 commits from events/audit-logger-assigned into main 2026-09-05 01:06:59 +00:00
38 changed files with 668 additions and 51 deletions
Showing only changes of commit 9e156a5b9e - Show all commits
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
+18
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+24
View File
@@ -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"
}
}
]
}
+14 -4
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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");
+24
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+18
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<IcecastStatus> {
+24
View File
@@ -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"
}
}
]
}
+13 -3
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+26 -1
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
+28 -1
View File
@@ -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"
}
}
+14 -4
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+24
View File
@@ -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"
}
}
]
}
+12 -3
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+25
View File
@@ -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"
}
}
]
}
+13 -4
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+25
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<string, string> = {}): Record<string, string> {
+24
View File
@@ -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"
}
}
]
}
+13 -3
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+18
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
+18
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+19
View File
@@ -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"
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+34 -1
View File
@@ -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"
}
}
+13 -3
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+18
View File
@@ -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"
}
}
]
}
+11 -1
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+32 -1
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<Record<string, unknown>>;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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);
}
+18
View File
@@ -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"
}
}
]
}
+12 -2
View File
@@ -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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<T>(path: string): Promise<T> {
+16
View File
@@ -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"
}
}
]
}