Stand up mesh-sdk — the stable spine a module builds against
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
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user