// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0039). // portainer is tools-only: its "events" would really be the underlying containers' lifecycle, // 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; type: number; url: string; status: number; } export interface PortainerStack { id: number; name: string; type: number; endpointId: number; status: number; } export interface PortainerContainer { id: string; names: string[]; image: string; state: string; status: string; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { 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; constructor( url: string, private readonly token: string, ) { this.baseUrl = url.replace(/\/+$/, ""); } /** * Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local * dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in * Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module * exposes nothing rather than calling Portainer unauthenticated. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { 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); } private async get(path: string): Promise { const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } }); if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`); return res.json() as Promise; } /** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */ async listEndpoints(): Promise { const raw = await this.get("/api/endpoints"); return (raw ?? []).map((e) => ({ id: e.Id, name: e.Name, type: e.Type, url: e.URL, status: e.Status, })); } /** The stacks (compose/swarm deployments) Portainer knows about. */ async listStacks(): Promise { const raw = await this.get("/api/stacks"); return (raw ?? []).map((s) => ({ id: s.Id, name: s.Name, type: s.Type, endpointId: s.EndpointId, status: s.Status, })); } /** * The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped * containers, so the caller sees the whole picture rather than only what is running. */ async listContainers(endpointId: number): Promise { const raw = await this.get(`/api/endpoints/${endpointId}/docker/containers/json?all=1`); return (raw ?? []).map((c) => ({ id: c.Id, names: c.Names ?? [], image: c.Image, state: c.State, status: c.Status, })); } }