// The Docker Registry v2 client — registry's own code, living in the module (novox/hq ADR 0039). // Ported from the shared hal sdk, where a change to the registry API rebuilt everything; here it // rebuilds only registry. Both this module's tools and its events entrypoint import it. export interface RegistryImage { repo: string; tag: string; digest: string; } export class RegistryClient { readonly baseUrl: string; // Auth is optional: a mesh-internal registry often runs open on the node, so a Basic header is // sent only when credentials were configured — an empty one would look like a failed login. constructor( url: string, private readonly authHeader?: string, ) { this.baseUrl = url.replace(/\/+$/, ""); } /** * Build from the module's resolved environment. The URL is MESH_REGISTRY_URL (or the local * registry port), and credentials — if the registry requires them — are MESH_REGISTRY_USER and * MESH_REGISTRY_PASSWORD. Throws when no URL is configured, so a misconfigured module exposes * nothing rather than talking to the wrong place. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): RegistryClient { const url = env.MESH_REGISTRY_URL ?? `http://127.0.0.1:${env.REGISTRY_PORT ?? "5000"}`; if (!url) throw new Error("no registry URL — set MESH_REGISTRY_URL"); const user = env.MESH_REGISTRY_USER; const password = env.MESH_REGISTRY_PASSWORD; const authHeader = user && password ? `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}` : undefined; return new RegistryClient(url, authHeader); } private headers(extra: Record = {}): Record { return { ...(this.authHeader ? { Authorization: this.authHeader } : {}), ...extra }; } private async getJson(path: string): Promise { const res = await fetch(`${this.baseUrl}${path}`, { headers: this.headers() }); if (!res.ok) throw new Error(`Registry ${path}: ${res.status} ${await res.text()}`); return res.json() as Promise; } /** The catalog — every repository the registry holds. */ async listRepositories(): Promise { const data = await this.getJson<{ repositories: string[] | null }>("/v2/_catalog"); return data.repositories ?? []; } /** The tags of one repository. */ async listTags(repo: string): Promise { const data = await this.getJson<{ tags: string[] | null }>(`/v2/${repo}/tags/list`); return data.tags ?? []; } /** The content digest of a repo:tag — the stable identity a tag currently points at. */ async getManifestDigest(repo: string, tag: string): Promise { const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${tag}`, { method: "HEAD", headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }), }); if (!res.ok) throw new Error(`Registry manifest ${repo}:${tag}: ${res.status} ${await res.text()}`); const digest = res.headers.get("docker-content-digest"); if (!digest) throw new Error(`no Docker-Content-Digest for ${repo}:${tag}`); return digest; } /** Delete a manifest by digest. Garbage collection reclaims the storage later. */ async deleteManifest(repo: string, digest: string): Promise { const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${digest}`, { method: "DELETE", headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }), }); if (!res.ok) throw new Error(`Registry delete ${repo}@${digest}: ${res.status} ${await res.text()}`); } }