// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0044). // 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. 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; } 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 url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; const 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, })); } }