Files
mesh-sdk/src/provisioner/index.ts
T
jschoubben 436f12edce provisioner: a provider consumes the mesh's credential, seals nothing (ADR 0053)
runProvisioner now reconciles the mesh's `receives` contributions: for each
consumer it reads the mesh-minted password from the file the host unsealed and calls
the adapter to create the resource under the login the mesh derived. The adapter is
create({as,password,values}) / remove({as}), returning nothing — the consumer
already receives its copy through the mesh's own asymmetric channel. $MESH_SEAL_KEY,
the symmetric seal()/writeSealedCredential path, and the *.grant.json / *.credential
files are gone; the seal()/unseal() primitive had no other caller and was removed.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 00:27:24 +02:00

163 lines
7.1 KiB
TypeScript

// The reconcile harness every provider shares. The loop below is identical for postgres, redis,
// minio and umami: read the contributions the mesh delivered, bring each consumer's resource into
// being through the provider's adapter under the login and password the mesh minted, and withdraw
// what the mesh no longer asks for. 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).
//
// **A provider creates the credential the mesh minted, and seals nothing (novox/hq ADR 0053).** The
// control plane mints one password per consumer and seals it to this node; the host unseals it into
// the file the contribution names. The provider does not generate a password, does not seal one, and
// does not hand one back — the consumer already receives its copy through the mesh's own channel.
// This is why the old $MESH_SEAL_KEY, the symmetric seal, and the *.grant.json / *.credential files
// are gone: they described a second, contradictory credential path the mesh does not have.
import { readFile } from "node:fs/promises";
/** One consumer's resource to bring into being — everything the mesh derived and delivered. */
export interface Provision {
/** The login the mesh derived and gave the consumer to present. The provider creates exactly
* this name — a name the consumer cannot learn is a name it cannot authenticate with. */
readonly as: string;
/** The password the mesh minted for this consumer, read from the file the mesh sealed to this
* node and the host unsealed. The provider sets it; it never invents one. */
readonly password: string;
/** What the consumer contributed, per the interface's spec keys (e.g. `{ name: "umami" }`). */
readonly values: Readonly<Record<string, unknown>>;
/** Where the consumer is, for a provider that must reach back to it. Usually unused. */
readonly at?: string;
/** The consumer node, for logging and lifecycle events. */
readonly consumer?: string;
}
/** What a provider implements — the only per-service code. It is handed the login and password the
* mesh made and brings the resource into being under them, or withdraws it. It returns nothing:
* the credential is the mesh's, already delivered to the consumer. */
export interface Adapter {
create(p: Provision): Promise<void>;
remove(p: { readonly as: string }): Promise<void>;
}
export interface ProvisionerOptions {
/** The contributions file the mesh writes for this resource (the module's `receives` path).
* Defaults to $MESH_RECEIVES. */
receives?: string;
/** Reconcile interval in ms. Defaults to 5000. */
everyMs?: number;
}
/** One entry in the mesh's contributions file: a consumer the provider must serve. */
interface Contribution {
readonly as: string;
readonly secret: string;
readonly node?: string;
readonly at?: string;
readonly values?: Readonly<Record<string, unknown>>;
}
/**
* Run the reconcile loop for one provided resource. Returns a stop function. Never throws for a
* single bad contribution — 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 receives = opts.receives ?? envOrThrow("MESH_RECEIVES");
const everyMs = opts.everyMs ?? 5000;
const applied = new Map<string, string>(); // login (`as`) -> hash of what was last applied
let stopped = false;
async function reconcile(): Promise<void> {
const given = await readContributions(receives, resource);
const wantByAs = new Map(given.map((g) => [g.as, g]));
// Create or update every consumer whose login, password or values changed.
for (const g of given) {
let password: string;
try {
// The file the mesh sealed to this node, unsealed by the host into plaintext. Trailing
// newline trimmed: a sealed value is exactly the secret, and a host writing a file may add
// one.
password = (await readFile(g.secret, "utf8")).replace(/\n$/, "");
} catch (err) {
// The contribution names a secret the host has not written yet — a normal race on the
// first pass. Skipped, retried on the next tick, never fatal.
console.error(`[provisioner:${resource}] ${g.as}: secret not readable yet (${g.secret}): ${err}`);
continue;
}
const h = hash(g.as, password, g.values ?? {});
if (applied.get(g.as) === h) continue;
try {
await adapter.create({ as: g.as, password, values: g.values ?? {}, at: g.at, consumer: g.node });
applied.set(g.as, h);
} catch (err) {
console.error(`[provisioner:${resource}] ${g.as}: create failed, will retry: ${err}`);
}
}
// Withdraw every login the provider made that the mesh no longer asks for. The mesh drops a
// consumer from the file when it goes away; that is how a login is withdrawn rather than kept
// working for ever. Only logins this harness created are touched.
for (const as of [...applied.keys()]) {
if (wantByAs.has(as)) continue;
try {
await adapter.remove({ as });
applied.delete(as);
} catch (err) {
console.error(`[provisioner:${resource}] ${as}: 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;
};
}
/**
* Read the mesh's contributions file for one resource. Absent or empty means "no consumer asks for
* this" — the file is always written, so a provider can tell that from "the mesh never wrote it".
*/
async function readContributions(path: string, resource: string): Promise<Contribution[]> {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch {
return []; // not written yet, or nobody serves anything here — nothing to converge to
}
let doc: { requirement?: string; given?: Contribution[] };
try {
doc = JSON.parse(raw) as { requirement?: string; given?: Contribution[] };
} catch (err) {
console.error(`[provisioner:${resource}] contributions file is not JSON (${path}): ${err}`);
return [];
}
if (doc.requirement && doc.requirement !== resource) {
console.error(`[provisioner:${resource}] ${path} is for ${doc.requirement}, not ${resource}`);
return [];
}
// A contribution with no `as` is not a credential grant (a module offering something on its own
// machine) — the provisioner has nothing to create for it.
return (doc.given ?? []).filter((g) => g.as && g.secret);
}
function hash(as: string, password: string, values: Readonly<Record<string, unknown>>): string {
return JSON.stringify([as, password, 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;
}