Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0044). Only
|
|
// this module's tools import it; nodered has nothing to poll, so there is no events entrypoint.
|
|
//
|
|
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
|
|
// 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;
|
|
label: string;
|
|
disabled: boolean;
|
|
}
|
|
|
|
export interface NodeRedNodeModule {
|
|
name: string;
|
|
version: string;
|
|
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;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly token?: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the
|
|
* "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing
|
|
* rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on;
|
|
* a default install needs none.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient {
|
|
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, cfg.token ?? env.MESH_NODERED_TOKEN);
|
|
}
|
|
|
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };
|
|
}
|
|
|
|
private async req(path: string, init: RequestInit = {}): Promise<any> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, init);
|
|
if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`);
|
|
return res.json();
|
|
}
|
|
|
|
/** The full flow configuration — the flat array of every node across every tab. */
|
|
async getConfig(): Promise<any[]> {
|
|
const body = await this.req("/flows", { headers: this.headers() });
|
|
// /flows answers a bare array by default, or { rev, flows } to a v2-aware client.
|
|
return Array.isArray(body) ? body : (body.flows ?? []);
|
|
}
|
|
|
|
/** The tabs (flows), each a node of type "tab" in the configuration. */
|
|
async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> {
|
|
const config = await this.getConfig();
|
|
const flows = config
|
|
.filter((n) => n.type === "tab")
|
|
.map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled }));
|
|
return { flows, nodeCount: config.length };
|
|
}
|
|
|
|
async listNodes(): Promise<NodeRedNodeModule[]> {
|
|
const modules = (await this.req("/nodes", { headers: this.headers() })) as any[];
|
|
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
|
|
}
|
|
|
|
/**
|
|
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
|
|
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
|
|
*/
|
|
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
|
|
const body = await this.req("/flows", {
|
|
method: "POST",
|
|
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
|
|
body: JSON.stringify(config),
|
|
});
|
|
return { rev: body?.rev, nodeCount: config.length };
|
|
}
|
|
}
|