diff --git a/src/provisioner/index.ts b/src/provisioner/index.ts index 2c1b8d9..cfb48da 100644 --- a/src/provisioner/index.ts +++ b/src/provisioner/index.ts @@ -23,6 +23,16 @@ export interface Provision { readonly password: string; /** What the consumer contributed, per the interface's spec keys (e.g. `{ name: "umami" }`). */ readonly values: Readonly>; + /** What this provider's own definition said it derives for this consumer, already derived by + * the mesh (novox/hq ADR 0201). + * + * **The rule is declared, not computed here.** A provider that names the resource it gives each + * consumer — a bucket, a database, a vhost — writes that name in its `serves` block with + * `${consumer:as}` or `${consumer:as:dns}` in it. The mesh fills it for this consumer and hands + * the same filled value to the consumer's own configuration, so the two ends cannot disagree. + * An adapter reads it from here; an adapter that recomputed it would be the second rule this + * removed. Empty for a provider that derives nothing. */ + readonly derived: Readonly>; /** 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. */ @@ -34,7 +44,12 @@ export interface Provision { * the credential is the mesh's, already delivered to the consumer. */ export interface Adapter { create(p: Provision): Promise; - remove(p: { readonly as: string }): Promise; + /** Withdraw what `create` made for this consumer. `derived` is what the mesh last derived for + * it, remembered by the harness: the mesh has already dropped the consumer from the + * contributions file by the time this is called, so it cannot be read again, and a provider + * that recomputed it would be keeping the second copy of a rule this removed + * (novox/hq ADR 0201). */ + remove(p: { readonly as: string; readonly derived: Readonly> }): Promise; /** Optional: whether the backend still holds this consumer's credential exactly as `p` says. * Asked of every consumer already applied, every `verifyEveryMs`. `false` makes the harness apply * it again on the same pass, so a backend that lost what it was given (a server restarted @@ -69,6 +84,7 @@ interface Contribution { readonly node?: string; readonly at?: string; readonly values?: Readonly>; + readonly derived?: Readonly>; } /** @@ -88,7 +104,9 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi // hour rather than one a minute, and it is said loudly rather than quietly repeated. const lost = new Map(); - const applied = new Map(); // login (`as`) -> hash of what was last applied + // login (`as`) -> what was last applied: the hash that says whether it changed, and the + // derivation to hand back on withdrawal, which by then is no longer in the file. + const applied = new Map> }>(); let stopped = false; async function reconcile(): Promise { @@ -112,12 +130,14 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi console.error(`[provisioner:${resource}] ${g.as}: secret not readable yet (${g.secret}): ${err}`); continue; } - const h = hash(g.as, password, g.values ?? {}); - const p: Provision = { as: g.as, password, values: g.values ?? {}, at: g.at, consumer: g.node }; + const h = hash(g.as, password, g.values ?? {}, g.derived ?? {}); + const p: Provision = { + as: g.as, password, values: g.values ?? {}, derived: g.derived ?? {}, at: g.at, consumer: g.node, + }; // Set when the backend reported this consumer lost: how many successful re-applies in a row // it has now needed, counting this one. let reapplying: number | undefined; - if (applied.get(g.as) === h) { + if (applied.get(g.as)?.hash === h) { if (!verifying) continue; const brake = lost.get(g.as); if (brake && Date.now() < brake.nextAt) continue; @@ -148,7 +168,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi } try { await adapter.create(p); - applied.set(g.as, h); + applied.set(g.as, { hash: h, derived: p.derived }); if (reapplying === undefined) { // Applied for a new login, password or values: whatever was counted before does not carry over. lost.delete(g.as); @@ -171,10 +191,10 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi // 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()]) { + for (const [as, was] of [...applied.entries()]) { if (wantByAs.has(as)) continue; try { - await adapter.remove({ as }); + await adapter.remove({ as, derived: was.derived }); applied.delete(as); lost.delete(as); } catch (err) { @@ -247,8 +267,16 @@ function scrub(err: unknown, password: string): string { return text; } -function hash(as: string, password: string, values: Readonly>): string { - return JSON.stringify([as, password, values]); +function hash( + as: string, + password: string, + values: Readonly>, + derived: Readonly>, +): string { + // The derivation is in the hash because it can change without the login or the password + // changing: a provider that renames what it derives has given this consumer a different + // resource, and an adapter trusted from memory would never be told (novox/hq ADR 0201). + return JSON.stringify([as, password, values, derived]); } function envOrThrow(name: string): string { diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 39034ad..6644f64 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -74,6 +74,55 @@ test("provisioner creates each consumer with the mesh's login and password, remo stop(); }); +test("provisioner hands the adapter what the mesh derived, and still has it at withdrawal", async () => { + // novox/hq ADR 0201: the provider declares what it derives for each consumer and the mesh fills + // it. The adapter must be able to read it on create, must see a changed derivation as a change + // worth re-applying, and must still be told it when the consumer is withdrawn — by which time + // the mesh has already dropped it from the file. + const dir = await mkdtemp(join(tmpdir(), "prov-derived-")); + const created: unknown[] = []; + const removed: unknown[] = []; + + await writeFile(join(dir, "ncloud.secret"), "minted-pw\n"); + const receives = join(dir, "store.json"); + const doc = (given: unknown[]): string => + JSON.stringify({ contributions: 1, requirement: "s3-bucket", given }); + const entry = (bucket: string) => ({ + from: "nextcloud", node: "anchor", as: "mesh_anchor_ncloud", + secret: join(dir, "ncloud.secret"), values: {}, derived: { bucket }, + }); + await writeFile(receives, doc([entry("mesh-anchor-ncloud")])); + + const stop = runProvisioner( + "s3-bucket", + { + async create(p) { + created.push(p.derived); + }, + async remove(p) { + removed.push(p.derived); + }, + }, + { receives, everyMs: 20 }, + ); + + await waitFor(() => created.length === 1, 2000); + assert.deepEqual(created[0], { bucket: "mesh-anchor-ncloud" }); + + // A changed derivation is a changed consumer: the login and the password are the same, and the + // resource it is being given is not. Trusted from memory, this would never be applied. + await writeFile(receives, doc([entry("mesh-anchor-files")])); + await waitFor(() => created.length === 2, 2000); + assert.deepEqual(created[1], { bucket: "mesh-anchor-files" }); + + // Withdrawn: the file no longer says anything about this consumer, and the adapter is still + // told which bucket to take away. + await writeFile(receives, doc([])); + await waitFor(() => removed.length === 1, 2000); + assert.deepEqual(removed[0], { bucket: "mesh-anchor-files" }); + stop(); +}); + test("provisioner applies again what the backend no longer holds, and trusts memory without holds", async () => { const dir = await mkdtemp(join(tmpdir(), "prov-holds-")); await writeFile(join(dir, "webapp.secret"), "minted-pw\n");