Merge pull request 'A provisioner is told what the mesh derived for each consumer (hq ADR 0201, issue 124)' (#10) from feat/a-provider-declares-what-it-derives into main
mesh/delivery delivered
mesh/delivery delivered
This commit is contained in:
+38
-10
@@ -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<Record<string, unknown>>;
|
||||
/** 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<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. */
|
||||
@@ -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<void>;
|
||||
remove(p: { readonly as: string }): Promise<void>;
|
||||
/** 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<Record<string, unknown>> }): Promise<void>;
|
||||
/** 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<Record<string, unknown>>;
|
||||
readonly derived?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<string, { times: number; nextAt: number }>();
|
||||
|
||||
const applied = new Map<string, string>(); // 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<string, { hash: string; derived: Readonly<Record<string, unknown>> }>();
|
||||
let stopped = false;
|
||||
|
||||
async function reconcile(): Promise<void> {
|
||||
@@ -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<Record<string, unknown>>): string {
|
||||
return JSON.stringify([as, password, values]);
|
||||
function hash(
|
||||
as: string,
|
||||
password: string,
|
||||
values: Readonly<Record<string, unknown>>,
|
||||
derived: Readonly<Record<string, unknown>>,
|
||||
): 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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user