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
163 lines
7.1 KiB
TypeScript
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;
|
|
}
|