Per novox/hq ADR 0044/0045: the sdk holds only what rarely changes and is shared across modules; per-module code (a client, tool impls, a create-a-resource adapter) lives in the module. Five areas, real and tested: - contracts: the runtime shapes module code touches (grant, credential, a mesh Interface, tool + envelope types) — not the manifest schema, which the control plane owns. - provisioner: the reconcile harness every provider shares (watch grants, create via the module's adapter, seal + write the credential, remove on withdrawal). A module writes only the adapter. - tools: registerModuleTools + collectTools — the serving harness; tools and their client live in the module. - messaging: the Broker/Envelope/event contract over the mesh broker; the concrete binding is provided by the hosting runtime. - primitives: AES-256-GCM seal/unseal, semver, resolved-env access. Compiles (tsc, NodeNext) and passes tests: sealing round-trip + wrong-key rejection, semver, tool registration (a thrower is skipped not fatal), and the provisioner creating then removing a sealed grant. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
|
|
// minio and umami: read the grants the control plane has written, bring each requested resource to
|
|
// existence through the provider's adapter, seal and hand back the credential, and remove what is
|
|
// no longer requested. A module writes ONLY the adapter — the per-service half — which is why this
|
|
// lives in the sdk and the adapter lives in the module (novox/hq ADR 0044).
|
|
|
|
import { readdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import type { Grant, Credential } from "../contracts/index.js";
|
|
import { seal } from "../primitives/index.js";
|
|
|
|
/** What a provider implements — the only per-service code. It is handed a Grant and creates (or
|
|
* removes) the resource in its own software, returning the credential a consumer receives. */
|
|
export interface Adapter {
|
|
create(grant: Grant): Promise<Credential>;
|
|
remove(grant: Grant): Promise<void>;
|
|
}
|
|
|
|
export interface ProvisionerOptions {
|
|
/** Directory the control plane writes grant requests into and the harness writes credentials to.
|
|
* Defaults to $GRANTS. */
|
|
grants?: string;
|
|
/** Passphrase a credential is sealed to before it is written. Defaults to $MESH_SEAL_KEY. */
|
|
sealKey?: string;
|
|
/** Reconcile interval in ms. Defaults to 5000. */
|
|
everyMs?: number;
|
|
}
|
|
|
|
export type { Grant, Credential };
|
|
|
|
/**
|
|
* Run the reconcile loop for one provided resource. Returns a stop function. Never throws for a
|
|
* single bad grant — it logs and keeps converging, because one consumer's failure must not stop
|
|
* the others' provisioning.
|
|
*/
|
|
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): () => void {
|
|
const dir = opts.grants ?? envOrThrow("GRANTS");
|
|
const sealKey = opts.sealKey ?? envOrThrow("MESH_SEAL_KEY");
|
|
const everyMs = opts.everyMs ?? 5000;
|
|
|
|
const applied = new Map<string, string>(); // consumer -> hash of the grant last applied
|
|
let stopped = false;
|
|
|
|
async function reconcile(): Promise<void> {
|
|
const requested = await readGrants(dir, resource);
|
|
const wantById = new Map(requested.map((g) => [g.consumer, g]));
|
|
|
|
// Create or update anything requested whose grant has changed.
|
|
for (const grant of requested) {
|
|
const h = hash(grant);
|
|
if (applied.get(grant.consumer) === h) continue;
|
|
try {
|
|
const cred = await adapter.create(grant);
|
|
await writeSealedCredential(dir, grant, cred, sealKey);
|
|
applied.set(grant.consumer, h);
|
|
} catch (err) {
|
|
console.error(`[provisioner:${resource}] ${grant.consumer}: create failed, will retry: ${err}`);
|
|
}
|
|
}
|
|
|
|
// Remove anything applied that is no longer requested. Only the harness's own outputs are
|
|
// touched — the credential file it wrote — never anything it did not create.
|
|
for (const consumer of [...applied.keys()]) {
|
|
if (wantById.has(consumer)) continue;
|
|
const grant = lastGrant(dir, resource, consumer);
|
|
try {
|
|
await adapter.remove(grant);
|
|
await rm(credentialPath(dir, resource, consumer), { force: true });
|
|
applied.delete(consumer);
|
|
} catch (err) {
|
|
console.error(`[provisioner:${resource}] ${consumer}: remove failed, will retry: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const tick = async (): Promise<void> => {
|
|
if (stopped) return;
|
|
try {
|
|
await reconcile();
|
|
} catch (err) {
|
|
console.error(`[provisioner:${resource}] reconcile error: ${err}`);
|
|
}
|
|
if (!stopped) setTimeout(() => void tick(), everyMs);
|
|
};
|
|
void tick();
|
|
|
|
return () => {
|
|
stopped = true;
|
|
};
|
|
}
|
|
|
|
// --- grant/credential files ---
|
|
|
|
async function readGrants(dir: string, resource: string): Promise<Grant[]> {
|
|
let names: string[];
|
|
try {
|
|
names = await readdir(dir);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out: Grant[] = [];
|
|
for (const name of names) {
|
|
if (!name.endsWith(".grant.json")) continue;
|
|
try {
|
|
const raw = await readFile(join(dir, name), "utf8");
|
|
const g = JSON.parse(raw) as Grant;
|
|
if (g.resource === resource) out.push(g);
|
|
} catch (err) {
|
|
console.error(`[provisioner:${resource}] unreadable grant ${name}: ${err}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function credentialPath(dir: string, resource: string, consumer: string): string {
|
|
return join(dir, `${consumer}.${resource}.credential`);
|
|
}
|
|
|
|
async function writeSealedCredential(dir: string, grant: Grant, cred: Credential, key: string): Promise<void> {
|
|
const body = JSON.stringify({ resource: grant.resource, consumer: grant.consumer, fields: cred.fields });
|
|
await writeFile(credentialPath(dir, grant.resource, grant.consumer), seal(body, key), { mode: 0o600 });
|
|
}
|
|
|
|
function lastGrant(dir: string, resource: string, consumer: string): Grant {
|
|
// For removal the harness needs a Grant to hand the adapter; the identity is enough to act on.
|
|
return { resource, consumer, node: "", values: {} };
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
function hash(g: Grant): string {
|
|
return JSON.stringify([g.resource, g.consumer, g.node, g.values]);
|
|
}
|
|
|
|
function envOrThrow(name: string): string {
|
|
const v = process.env[name];
|
|
if (!v) throw new Error(`${name} is not set — the provisioner cannot run without it`);
|
|
return v;
|
|
}
|