Name the two modules after their software: nftables and distribution
A module's identity is the software it is (ADR 0040). Two were named after the job instead, and the job already had a name. firewall installs the nftables package and runs nftables.service. The seat it claims is the-packet-filter, which is correctly named for the role. Calling the module firewall named neither the software nor the provision, and promised that any firewall could sit there — the false genericity the naming rule forbids. registry runs Distribution, the OCI reference implementation, and provides artifact-store. So registry was a third name for a thing that already had two, which is how one word ended up meaning the module, the software and the concept in the same paragraph. The capability stays firewall, and correctly: a capability IS a functionality, so a node having one and fail2ban requiring one are both right. Only the module moves. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// 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<string, string> = {}): Record<string, string> {
|
||||
return { ...(this.authHeader ? { Authorization: this.authHeader } : {}), ...extra };
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
/** The catalog — every repository the registry holds. */
|
||||
async listRepositories(): Promise<string[]> {
|
||||
const data = await this.getJson<{ repositories: string[] | null }>("/v2/_catalog");
|
||||
return data.repositories ?? [];
|
||||
}
|
||||
|
||||
/** The tags of one repository. */
|
||||
async listTags(repo: string): Promise<string[]> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// registry's events. The tool runtime imports this once the broker is bound.
|
||||
//
|
||||
// Emits (novox/hq ADR 0041/0042):
|
||||
// module.registry.image.pushed — a new image (repo:tag) was published to the registry
|
||||
//
|
||||
// This is a genuinely useful signal: a build finished and its image is now pullable, so anything
|
||||
// on the mesh that redeploys, mirrors or announces releases can react without polling the registry
|
||||
// itself. It is discovered by diffing the catalog and each repo's tags — the registry has no push
|
||||
// webhook of its own, so the module watches for it.
|
||||
//
|
||||
// The polling is deliberately unhurried: a new image a minute late is still the event, whereas
|
||||
// hammering the registry's catalog for immediacy nobody asked for is not.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { RegistryClient } from "./client.js";
|
||||
|
||||
const registry = RegistryClient.fromEnv();
|
||||
|
||||
// Every repo:tag we have already accounted for. Primed silently on the first look so a registry
|
||||
// that was already full when this started does not announce its whole history as freshly pushed.
|
||||
const seen = new Set<string>();
|
||||
let primed = false;
|
||||
|
||||
async function pollCatalog(): Promise<void> {
|
||||
const repos = await registry.listRepositories();
|
||||
for (const repo of repos) {
|
||||
let tags: string[];
|
||||
try {
|
||||
tags = await registry.listTags(repo);
|
||||
} catch {
|
||||
continue; // a repo can vanish between catalog and tag read — skip it, catch it next tick
|
||||
}
|
||||
for (const tag of tags) {
|
||||
const id = `${repo}:${tag}`;
|
||||
if (!seen.has(id)) {
|
||||
if (primed) await emit("module.registry.image.pushed", { repo, tag });
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[registry] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollCatalog, 60_000);
|
||||
|
||||
console.log("[registry] watching the catalog for newly pushed images");
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"module": "distribution",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "artifact-store",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-artifact-store",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.registry.image.pushed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/registry/broker"
|
||||
},
|
||||
"serves": {
|
||||
"artifact-store": {
|
||||
"port": 5000
|
||||
}
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 5000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "every machine pulls images and artifacts from here"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/registry",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "container",
|
||||
"name": "mesh-registry",
|
||||
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||
"ports": [
|
||||
"5000:5000"
|
||||
],
|
||||
"volumes": [
|
||||
"mesh-registry-data:/var/lib/registry"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-registry",
|
||||
"version": "0.1.0",
|
||||
"description": "registry — private Docker image registry. Its API client, tools and events live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// registry's tools — moved here from the shared sdk (novox/hq ADR 0039), importing registry's own
|
||||
// client. They return structured data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { RegistryClient } from "../client.js";
|
||||
|
||||
export function getRegistryTools(registry: RegistryClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "registry_list",
|
||||
description: "List every repository in the Docker registry (the catalog).",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const repositories = await registry.listRepositories();
|
||||
return { count: repositories.length, repositories };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "registry_tags",
|
||||
description: "List the tags of one repository in the Docker registry.",
|
||||
input: { repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" } },
|
||||
run: async (args) => {
|
||||
const repo = String(args.repo);
|
||||
const tags = await registry.listTags(repo);
|
||||
return { repo, count: tags.length, tags };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "registry_delete_image",
|
||||
description:
|
||||
"Delete an image tag from the registry (DESTRUCTIVE). Removes the manifest; storage is reclaimed by garbage collection later. Requires confirm: true.",
|
||||
input: {
|
||||
repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" },
|
||||
tag: { type: "string", description: "the tag to delete, e.g. 'latest'" },
|
||||
confirm: { type: "boolean", description: "must be true to actually delete" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const repo = String(args.repo);
|
||||
const tag = String(args.tag);
|
||||
if (args.confirm !== true) {
|
||||
return { deleted: false, reason: "confirm must be true to delete an image" };
|
||||
}
|
||||
const digest = await registry.getManifestDigest(repo, tag);
|
||||
await registry.deleteManifest(repo, digest);
|
||||
return { deleted: true, repo, tag, digest, note: "run registry garbage collection to reclaim storage" };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a registry URL is configured; otherwise registry contributes none
|
||||
// rather than failing the whole runtime.
|
||||
registerModuleTools("registry", (env) => {
|
||||
try {
|
||||
return getRegistryTools(RegistryClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user