Retire only after ten minutes as well as five passes, and never through remove
Five passes are 25 s, shorter than a real hiccup. And a provider's remove can destroy — a ledger file, a public DNS record — so an adapter without a retire of its own is mark-only: the consumer keeps its access, is marked and said, and remove is reached only by a person's delete (hq ADR 0230).
This commit is contained in:
@@ -29,17 +29,20 @@ module. This is the ADR 0039 line drawn through provisioning: stable loop here,
|
||||
in the module.
|
||||
|
||||
**A consumer the mesh stops asking for is retired, not withdrawn whole** (novox/hq ADR 0230). ACTIVE
|
||||
becomes RETIRED — access disabled through the adapter's `retire` (or `remove`, for an adapter that
|
||||
has none), marked with when and why, nothing deleted — only once the same set of consumers has gone
|
||||
unasked in five consecutive passes that read the file (20 s at the default 5 s interval; a pass that
|
||||
cannot read the file resets the count). A stable set of more than 3, or more than half of those held
|
||||
where more than one is held, is not retired without a person: the provider waits for
|
||||
becomes RETIRED only once the same set of consumers has gone unasked in five consecutive passes that
|
||||
read the file AND for ten minutes (`stableForMs`, `STABLE_FOR_MS`; a pass that cannot read the file
|
||||
starts both again). An adapter with `retire` disables the consumer's access, reversibly, and marks it;
|
||||
`reenable` (optional) undoes that before `create` when it is asked for again. **An adapter without
|
||||
`retire` is mark-only**: nothing in the backend changes — the consumer keeps its access — and the
|
||||
harness marks it "retired, access kept, needs deletion", says so loudly and announces it. **`remove` is
|
||||
never called to retire**; it is the delete of an adapter without `delete`, reached only through a
|
||||
person's `provisioner_delete`. A stable set of more than 3, or more than half of those held where more
|
||||
than one is held, is not retired without a person: the provider waits for
|
||||
`provisioner_retire_approve` or `provisioner_retire_reject`, and says `provisioner.retirement`
|
||||
(`waiting`) through the `announce` option. Asked for again, a retired consumer is created — re-enabled
|
||||
— at once. DELETED only through `provisioner_delete`, of a retired consumer. Given `module`, the four
|
||||
tools (`provisioner_retirement`, `provisioner_retire_approve`, `provisioner_retire_reject`,
|
||||
`provisioner_delete`) are registered for it; `provisionerTools(handle)` builds them otherwise.
|
||||
Additions and changes are never delayed.
|
||||
— at once. Given `module`, the four tools (`provisioner_retirement`, `provisioner_retire_approve`,
|
||||
`provisioner_retire_reject`, `provisioner_delete`) are registered for it; `provisionerTools(handle)`
|
||||
builds them otherwise. Additions and changes are never delayed.
|
||||
|
||||
### `tools` — the tool-serving harness
|
||||
`registerModuleTools`, the `ToolDefinition` type, and the registry/worker that loads a module's
|
||||
|
||||
+91
-35
@@ -47,11 +47,13 @@ export interface Provision {
|
||||
* the credential is the mesh's, already delivered to the consumer. */
|
||||
export interface Adapter {
|
||||
create(p: Provision): Promise<void>;
|
||||
/** Retire what `create` made for this consumer when the adapter has no `retire`: disable, never destroy (issue 241). `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 what `create` made for this consumer. **Called only to DELETE a retired consumer, on a
|
||||
* person's approved `cleanup delete`, and only when the adapter has no `delete`** (novox/hq ADR
|
||||
* 0230): many providers' remove destroys something — a ledger file, a public DNS record, a user —
|
||||
* and nothing is destroyed without a person. Retiring never calls it. `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 then, 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
|
||||
@@ -60,17 +62,22 @@ export interface Adapter {
|
||||
* of being trusted from memory (novox/hq issue 120). An adapter without it is trusted from memory,
|
||||
* as before. It must only read: it is asked often, and must never change the backend. */
|
||||
holds?(p: Provision): Promise<boolean>;
|
||||
/** Optional: RETIRE a consumer the mesh stopped asking for (novox/hq ADR 0230) — disable its
|
||||
/** Optional: RETIRE a consumer the mesh stopped asking for (novox/hq ADR 0230) — DISABLE its
|
||||
* access, reversibly, and mark it in the backend "to delete" with when and why; delete nothing.
|
||||
* `create` must re-enable what this disabled and clear the mark, so a consumer asked for again is
|
||||
* back as it was. An adapter without it is retired through `remove`, which since issue 241 already
|
||||
* disables rather than destroys. */
|
||||
* Asked for again, `reenable` (when given) and then `create` bring it back as it was.
|
||||
*
|
||||
* **An adapter without it is MARK-ONLY**: retiring a consumer then changes nothing in the backend —
|
||||
* its access stays as it was — and it is only marked, in this harness, "retired, access kept, needs
|
||||
* deletion", said loudly and announced. Only a person's `cleanup delete` then removes it. */
|
||||
retire?(p: {
|
||||
readonly as: string;
|
||||
readonly derived: Readonly<Record<string, unknown>>;
|
||||
readonly why: string;
|
||||
readonly at: Date;
|
||||
}): Promise<void>;
|
||||
/** Optional, with `retire`: undo what `retire` disabled, before `create` runs for a consumer asked
|
||||
* for again. An adapter whose `create` already re-enables needs none. */
|
||||
reenable?(p: { readonly as: string; readonly derived: Readonly<Record<string, unknown>> }): Promise<void>;
|
||||
/** Optional: every consumer the backend holds retired — what `cleanup list` shows. */
|
||||
retired?(): Promise<RetiredConsumer[]>;
|
||||
/** Optional: DELETE one retired consumer and its data. Only ever called for a consumer this
|
||||
@@ -92,6 +99,9 @@ export interface RetiredConsumer {
|
||||
readonly size_bytes: number;
|
||||
/** "consumer", or a backend's own word for something else it keeps retired. */
|
||||
readonly kind: string;
|
||||
/** "disabled" when the adapter retired it; "kept" for a mark-only provider, whose consumer keeps its
|
||||
* access until a person deletes it (novox/hq ADR 0230). */
|
||||
readonly access?: "disabled" | "kept";
|
||||
}
|
||||
|
||||
/** What `provisioner_retirement` answers: the provider's retirement state (novox/hq ADR 0230). */
|
||||
@@ -100,6 +110,9 @@ export interface Retirement {
|
||||
readonly node: string;
|
||||
readonly held: string[];
|
||||
readonly stable_passes: number;
|
||||
readonly stable_for_seconds: number;
|
||||
/** "disable" when the adapter can retire; "mark-only" when it cannot (novox/hq ADR 0230). */
|
||||
readonly retires_by: "disable" | "mark-only";
|
||||
readonly bound: string;
|
||||
readonly waiting: { consumers: { consumer: string; node?: string }[]; since: string; held: number } | null;
|
||||
readonly rejected: { consumers: { consumer: string; node?: string }[]; by: string; why: string; at: string } | null;
|
||||
@@ -140,9 +153,13 @@ export interface ProvisionerOptions {
|
||||
* loop is sequential, so a check that hangs would otherwise stop every consumer's provisioning. */
|
||||
holdsTimeoutMs?: number;
|
||||
/** How many consecutive passes that read the file must name the same set of consumers no longer
|
||||
* asked for before they are retired (novox/hq ADR 0230). Defaults to 5: at the default interval a
|
||||
* retirement comes 20 s after the first pass that saw it. Additions and changes are never delayed. */
|
||||
* asked for before they are retired (novox/hq ADR 0230). Defaults to 5. Additions and changes are
|
||||
* never delayed. */
|
||||
stablePasses?: number;
|
||||
/** And how long, in ms, that same set must have held since the first pass that saw it. Defaults to
|
||||
* STABLE_FOR_MS, ten minutes: five passes alone are 25 s, shorter than a controller restart, a store
|
||||
* reconnecting or a file half written. Both must hold. */
|
||||
stableForMs?: number;
|
||||
/** A stable set larger than this is not retired without a person. Defaults to 3. */
|
||||
retireAtOnce?: number;
|
||||
/** Nor a set larger than this share of the consumers held, where more than one is held. Defaults
|
||||
@@ -160,6 +177,9 @@ export interface ProvisionerOptions {
|
||||
module?: string;
|
||||
}
|
||||
|
||||
/** The least time the same unasked set must hold before it is retired (novox/hq ADR 0230). */
|
||||
export const STABLE_FOR_MS = 10 * 60_000;
|
||||
|
||||
/** The longest a consumer whose `create` keeps failing to satisfy `holds` waits between checks. */
|
||||
const MAX_BACKOFF_MS = 60 * 60_000;
|
||||
|
||||
@@ -190,8 +210,9 @@ interface Contribution {
|
||||
*
|
||||
* **A consumer the mesh stops asking for is retired, never withdrawn whole (novox/hq ADR 0230).**
|
||||
* ACTIVE → RETIRED once the same set of consumers has gone unasked in `stablePasses` consecutive
|
||||
* passes that read the file; RETIRED is access disabled and marked, nothing deleted, and asked for
|
||||
* again it is re-enabled at once. A stable set of more than `retireAtOnce`, or more than
|
||||
* passes that read the file AND for `stableForMs` (ten minutes); RETIRED is access disabled and
|
||||
* marked — or, for an adapter with no `retire`, only marked here with its access kept — nothing
|
||||
* deleted, and asked for again it is re-enabled at once. `remove` is never called to retire. A stable set of more than `retireAtOnce`, or more than
|
||||
* `retireFraction` of those held where more than one is held, is not retired without a person: the
|
||||
* provider waits for approve or reject. RETIRED → DELETED only through `provisioner_delete`.
|
||||
*/
|
||||
@@ -201,6 +222,8 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
const verifyEveryMs = opts.verifyEveryMs ?? 60_000;
|
||||
const holdsTimeoutMs = opts.holdsTimeoutMs ?? 30_000;
|
||||
const stablePasses = opts.stablePasses ?? 5;
|
||||
const stableForMs = opts.stableForMs ?? STABLE_FOR_MS;
|
||||
const retiresBy: "disable" | "mark-only" = adapter.retire ? "disable" : "mark-only";
|
||||
const retireAtOnce = opts.retireAtOnce ?? 3;
|
||||
const retireFraction = opts.retireFraction ?? 0.5;
|
||||
const sayAgainEveryMs = opts.sayAgainEveryMs ?? 15 * 60_000;
|
||||
@@ -209,10 +232,12 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
// Retirement (novox/hq ADR 0230). The unasked set last seen and in how many consecutive readable
|
||||
// passes; a set too large, waiting for a person; a set a person rejected; and every consumer known
|
||||
// retired, by name.
|
||||
let stable = { set: "", count: 0 };
|
||||
let stable = { set: "", count: 0, since: 0 };
|
||||
let pending: { consumers: string[]; since: number; held: number; saidAt: number } | null = null;
|
||||
let rejected: { consumers: string[]; by: string; why: string; at: number } | null = null;
|
||||
const retired = new Map<string, { node?: string; at: number; why: string }>();
|
||||
const retired = new Map<string, {
|
||||
node?: string; at: number; why: string; derived?: Readonly<Record<string, unknown>>; access: "disabled" | "kept";
|
||||
}>();
|
||||
let seeded = false;
|
||||
let verifiedAt = 0;
|
||||
// Consumers the backend reported lost, and how many times in a row. A `create` that succeeded and
|
||||
@@ -246,7 +271,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
// waits for the next one.
|
||||
if (given === null) {
|
||||
// Not a result: the passes that make a retirement stable must each have read the file.
|
||||
stable = { set: "", count: 0 };
|
||||
stable = { set: "", count: 0, since: 0 };
|
||||
return;
|
||||
}
|
||||
if (!seeded) await seed();
|
||||
@@ -326,6 +351,10 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
}
|
||||
}
|
||||
try {
|
||||
const was = retired.get(g.as);
|
||||
if (was && was.access === "disabled" && adapter.reenable) {
|
||||
await adapter.reenable({ as: g.as, derived: was.derived ?? p.derived });
|
||||
}
|
||||
await adapter.create(p);
|
||||
applied.set(g.as, { hash: h, derived: p.derived, node: g.node });
|
||||
if (retired.has(g.as)) {
|
||||
@@ -369,11 +398,13 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
rejected = null;
|
||||
}
|
||||
if (unasked.length === 0) {
|
||||
stable = { set: "", count: 0 };
|
||||
stable = { set: "", count: 0, since: 0 };
|
||||
return;
|
||||
}
|
||||
stable = stable.set === key ? { set: key, count: stable.count + 1 } : { set: key, count: 1 };
|
||||
if (stable.count < stablePasses) return;
|
||||
stable = stable.set === key
|
||||
? { set: key, count: stable.count + 1, since: stable.since }
|
||||
: { set: key, count: 1, since: Date.now() };
|
||||
if (stable.count < stablePasses || Date.now() - stable.since < stableForMs) return;
|
||||
if (rejected) return; // a person said keep them, and the mesh still says the same
|
||||
const held = applied.size;
|
||||
if (overTheBound(unasked.length, held)) {
|
||||
@@ -392,11 +423,16 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
}
|
||||
return;
|
||||
}
|
||||
const why = `the mesh stopped asking for it: ${stablePasses} passes from ${iso(Date.now() - (stablePasses - 1) * everyMs)}`;
|
||||
const why = `the mesh stopped asking for it: the same answer in ${stable.count} passes over ` +
|
||||
`${Math.round((Date.now() - stable.since) / 1000)}s, from ${iso(stable.since)}`;
|
||||
const done = await retireAll(unasked, why);
|
||||
if (done.length > 0) await tell("retired", { consumers: done.map((as) => retiredBody(as)), held, why });
|
||||
}
|
||||
|
||||
function retiresByAccess(): "disabled" | "kept" {
|
||||
return adapter.retire ? "disabled" : "kept";
|
||||
}
|
||||
|
||||
function overTheBound(n: number, held: number): boolean {
|
||||
return n > retireAtOnce || (held > 1 && n > retireFraction * held);
|
||||
}
|
||||
@@ -405,27 +441,36 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
return `more than ${retireAtOnce}, or more than ${Math.round(retireFraction * 100)}% of the ${held} held`;
|
||||
}
|
||||
|
||||
// Retire each, as it is said: disabled and marked by the adapter, nothing deleted. Answers those
|
||||
// that went; one that failed stays active and is asked again by the next stable pass.
|
||||
// Retire each, as it is said: disabled and marked by the adapter — or, where the adapter cannot
|
||||
// disable, only marked here, its access kept. Nothing is deleted, and `remove` is never called: it
|
||||
// is the delete of a provider without one. Answers those that went; one that failed stays active and
|
||||
// is asked again once its set is stable again.
|
||||
async function retireAll(names: readonly string[], why: string): Promise<string[]> {
|
||||
const done: string[] = [];
|
||||
for (const as of names) {
|
||||
const was = applied.get(as);
|
||||
if (was === undefined) continue;
|
||||
say(`${as}: no longer in ${receives}; retiring it (access disabled, nothing deleted): ${why}`);
|
||||
try {
|
||||
const at = new Date();
|
||||
if (adapter.retire) await adapter.retire({ as, derived: was.derived, why, at });
|
||||
else await adapter.remove({ as, derived: was.derived });
|
||||
if (adapter.retire) {
|
||||
say(`${as}: no longer in ${receives}; RETIRED — access disabled, nothing deleted: ${why}`);
|
||||
await adapter.retire({ as, derived: was.derived, why, at });
|
||||
} else {
|
||||
say(
|
||||
`${as}: no longer in ${receives}; RETIRED, MARK ONLY — this provider cannot disable a consumer ` +
|
||||
`(its adapter has no retire), so ${as} KEEPS ITS ACCESS and its data until a person deletes it ` +
|
||||
`with \`cleanup delete\`. Nothing was changed in the backend (novox/hq ADR 0230): ${why}`,
|
||||
);
|
||||
}
|
||||
applied.delete(as);
|
||||
lost.delete(as);
|
||||
retired.set(as, { node: was.node, at: at.getTime(), why });
|
||||
retired.set(as, { node: was.node, at: at.getTime(), why, derived: was.derived, access: adapter.retire ? "disabled" : "kept" });
|
||||
done.push(as);
|
||||
} catch (err) {
|
||||
say(`${as}: retiring failed, will retry: ${err}`);
|
||||
}
|
||||
}
|
||||
stable = { set: "", count: 0 };
|
||||
stable = { set: "", count: 0, since: 0 };
|
||||
return done;
|
||||
}
|
||||
|
||||
@@ -435,7 +480,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
|
||||
function retiredBody(as: string): Record<string, unknown> {
|
||||
const r = retired.get(as);
|
||||
return { consumer: as, node: r?.node, retired_at: r ? iso(r.at) : "", why: r?.why ?? "" };
|
||||
return { consumer: as, node: r?.node, retired_at: r ? iso(r.at) : "", why: r?.why ?? "", access: r?.access ?? retiresByAccess() };
|
||||
}
|
||||
|
||||
// Said on the bus, never failing the act it reports (the journal still has it).
|
||||
@@ -459,7 +504,9 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
}
|
||||
for (const r of (await adapter.retired?.()) ?? []) {
|
||||
if (r.kind === "consumer" && !retired.has(r.consumer)) {
|
||||
retired.set(r.consumer, { node: r.node, at: r.retired_at ? Date.parse(r.retired_at) : 0, why: r.why });
|
||||
retired.set(r.consumer, {
|
||||
node: r.node, at: r.retired_at ? Date.parse(r.retired_at) : 0, why: r.why, access: r.access ?? retiresByAccess(),
|
||||
});
|
||||
}
|
||||
}
|
||||
seeded = true;
|
||||
@@ -484,11 +531,15 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
const list: RetiredConsumer[] = [...fromBackend];
|
||||
for (const [as, r] of retired) {
|
||||
if (!list.some((x) => x.consumer === as && x.kind === "consumer")) {
|
||||
list.push({ consumer: as, node: r.node, retired_at: r.at ? iso(r.at) : "", why: r.why, size_bytes: -1, kind: "consumer" });
|
||||
list.push({
|
||||
consumer: as, node: r.node, retired_at: r.at ? iso(r.at) : "", why: r.why, size_bytes: -1, kind: "consumer",
|
||||
access: r.access,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
resource, node, held: [...applied.keys()].sort(), stable_passes: stablePasses, bound: bound(applied.size),
|
||||
resource, node, held: [...applied.keys()].sort(), stable_passes: stablePasses,
|
||||
stable_for_seconds: Math.round(stableForMs / 1000), retires_by: retiresBy, bound: bound(applied.size),
|
||||
waiting: pending && { consumers: named(pending.consumers), since: iso(pending.since), held: pending.held },
|
||||
rejected: rejected && { consumers: named(rejected.consumers), by: rejected.by, why: rejected.why, at: iso(rejected.at) },
|
||||
retired: list,
|
||||
@@ -546,11 +597,16 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
if (applied.has(consumer)) throw new Error(`delete: ${consumer} is active, not retired — only a retired consumer is deleted`);
|
||||
const known = retired.has(consumer) || ((await adapter.retired?.()) ?? []).some((r) => r.consumer === consumer);
|
||||
if (!known) throw new Error(`delete: ${consumer} is not retired here`);
|
||||
if (!adapter.delete) throw new Error(`delete: this provider (${resource}) cannot delete; its adapter has no delete`);
|
||||
say(`${consumer}: DELETING, asked by ${act.by || "someone"}: ${act.why}`);
|
||||
const r = await adapter.delete({ as: consumer, why: act.why });
|
||||
const freed = (r && typeof r.freedBytes === "number") ? r.freedBytes : -1;
|
||||
const was = retired.get(consumer);
|
||||
say(`${consumer}: DELETING, asked by ${act.by || "someone"}: ${act.why}`);
|
||||
let freed = -1;
|
||||
if (adapter.delete) {
|
||||
const r = await adapter.delete({ as: consumer, why: act.why });
|
||||
if (r && typeof r.freedBytes === "number") freed = r.freedBytes;
|
||||
} else {
|
||||
// The one path to `remove`: a person's approved deletion (novox/hq ADR 0230).
|
||||
await adapter.remove({ as: consumer, derived: was?.derived ?? {} });
|
||||
}
|
||||
retired.delete(consumer);
|
||||
await tell("deleted", {
|
||||
consumers: [{ consumer, node: was?.node, size_bytes: freed }], why: act.why, by: act.by ?? "", via: act.via ?? "",
|
||||
|
||||
+70
-10
@@ -1,6 +1,7 @@
|
||||
// Retirement (novox/hq ADR 0230): a consumer the mesh stops asking for is retired — access disabled,
|
||||
// data kept — only after the same result in five consecutive passes, and a set too large waits for a
|
||||
// person. Deletion is a person's act, of a retired consumer only.
|
||||
// data kept — only after the same result in five consecutive passes AND ten minutes (shortened here
|
||||
// unless a test is about it), and a set too large waits for a person. An adapter that cannot disable
|
||||
// is mark-only: remove is never called to retire. Deletion is a person's act, of a retired consumer only.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
@@ -28,13 +29,17 @@ interface World {
|
||||
retired: { as: string; why: string }[];
|
||||
removed: string[];
|
||||
deleted: string[];
|
||||
reenabled: string[];
|
||||
events: Record<string, unknown>[];
|
||||
said: string[];
|
||||
p: Provisioner;
|
||||
done(): void;
|
||||
}
|
||||
|
||||
async function world(names: string[], opts: { retire?: boolean; held?: string[] } = {}): Promise<World> {
|
||||
async function world(
|
||||
names: string[],
|
||||
opts: { retire?: boolean; held?: string[]; del?: boolean; reenable?: boolean; stableForMs?: number } = {},
|
||||
): Promise<World> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "retire-"));
|
||||
const receives = join(dir, "postgres-database.json");
|
||||
const ask = async (list: string[]) => {
|
||||
@@ -46,18 +51,19 @@ async function world(names: string[], opts: { retire?: boolean; held?: string[]
|
||||
await writeFile(receives, JSON.stringify({ contributions: 1, requirement: "postgres-database", given }));
|
||||
};
|
||||
await ask(names);
|
||||
const w = { receives, ask, created: [], retired: [], removed: [], deleted: [], events: [], said: [] } as unknown as World;
|
||||
const w = { receives, ask, created: [], retired: [], removed: [], deleted: [], reenabled: [], events: [], said: [] } as unknown as World;
|
||||
const was = console.error;
|
||||
console.error = (...a: unknown[]) => w.said.push(a.join(" "));
|
||||
const adapter = {
|
||||
async create(p: { as: string }) { w.created.push(p.as); },
|
||||
async remove(p: { as: string }) { w.removed.push(p.as); },
|
||||
async delete(p: { as: string }) { w.deleted.push(p.as); return { freedBytes: 42 }; },
|
||||
...(opts.del === false ? {} : { async delete(p: { as: string }) { w.deleted.push(p.as); return { freedBytes: 42 }; } }),
|
||||
...(opts.reenable ? { async reenable(p: { as: string }) { w.reenabled.push(p.as); } } : {}),
|
||||
...(opts.retire === false ? {} : { async retire(p: { as: string; why: string }) { w.retired.push({ as: p.as, why: p.why }); } }),
|
||||
...(opts.held ? { async held() { return opts.held!; } } : {}),
|
||||
};
|
||||
w.p = runProvisioner("postgres-database", adapter, {
|
||||
receives, everyMs: EVERY, node: "anchor",
|
||||
receives, everyMs: EVERY, node: "anchor", stableForMs: opts.stableForMs ?? 0,
|
||||
announce: (event, body) => { assert.equal(event, RETIREMENT_EVENT); w.events.push(body); },
|
||||
});
|
||||
// A pass is counted by the file being read: wait wall time for n passes and some slack.
|
||||
@@ -85,7 +91,7 @@ test("a transient empty list for four passes retires nothing; five stable passes
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.retired.length === 1);
|
||||
assert.equal(w.retired[0].as, "a");
|
||||
assert.match(w.retired[0].why, /5 passes/);
|
||||
assert.match(w.retired[0].why, /\d+ passes over/);
|
||||
assert.deepEqual(w.removed, []);
|
||||
assert.deepEqual(w.deleted, []);
|
||||
assert.deepEqual(changes(w), ["retired"]);
|
||||
@@ -144,17 +150,71 @@ test("asked for again, a retired consumer is created again and announced re-enab
|
||||
}
|
||||
});
|
||||
|
||||
test("an adapter without retire is retired through remove, as before", async () => {
|
||||
const w = await world(["a", "b", "c"], { retire: false });
|
||||
test("five passes are not enough: the same set must also hold for stableForMs", async () => {
|
||||
const HOLD = 400;
|
||||
const w = await world(["a", "b", "c"], { stableForMs: HOLD });
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.removed.length === 1);
|
||||
const start = Date.now();
|
||||
await sleep(EVERY * 10); // well past five passes, well short of the hold
|
||||
assert.deepEqual(w.retired, [], "retired after five passes, before the set held long enough");
|
||||
await until(() => w.retired.length === 1, 3000);
|
||||
assert.ok(Date.now() - start >= HOLD - 5, `retired after ${Date.now() - start} ms, before ${HOLD} ms`);
|
||||
assert.equal((await w.p.retirement()).stable_for_seconds, 0); // 400 ms rounds to 0 s
|
||||
} finally {
|
||||
w.done();
|
||||
}
|
||||
});
|
||||
|
||||
test("the default hold is ten minutes", async () => {
|
||||
const { STABLE_FOR_MS } = await import("../dist/provisioner/index.js");
|
||||
assert.equal(STABLE_FOR_MS, 10 * 60_000);
|
||||
});
|
||||
|
||||
test("an adapter without retire is MARK-ONLY: nothing changes in the backend, remove only on delete", async () => {
|
||||
const w = await world(["a", "b", "c"], { retire: false, del: false });
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => changes(w).includes("retired"));
|
||||
await sleep(EVERY * 10);
|
||||
assert.deepEqual(w.removed, [], "remove was called to retire");
|
||||
const ev = w.events.find((e) => e.change === "retired")!;
|
||||
assert.equal((ev.consumers as { access: string }[])[0].access, "kept");
|
||||
assert.ok(w.said.some((l) => /MARK ONLY/.test(l) && /KEEPS ITS ACCESS/.test(l)), "mark-only was not said loudly");
|
||||
const r = await w.p.retirement();
|
||||
assert.equal(r.retires_by, "mark-only");
|
||||
assert.deepEqual(r.retired.map((x) => [x.consumer, x.access]), [["a", "kept"]]);
|
||||
// Asked again: created again, nothing to undo.
|
||||
await w.ask(["a", "b", "c"]);
|
||||
await until(() => changes(w).includes("reenabled"));
|
||||
assert.deepEqual(w.removed, []);
|
||||
// Gone again, then a person's delete: the one path to remove.
|
||||
await w.ask(["b", "c"]);
|
||||
await until(async () => (await w.p.retirement()).retired.length === 1);
|
||||
await w.p.deleteRetired("a", { why: "gone", confirm: "a" });
|
||||
assert.deepEqual(w.removed, ["a"]);
|
||||
} finally {
|
||||
w.done();
|
||||
}
|
||||
});
|
||||
|
||||
test("an adapter with retire and reenable: disabled on retire, reenabled before create when asked again", async () => {
|
||||
const w = await world(["a", "b", "c"], { reenable: true });
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.retired.length === 1);
|
||||
assert.equal((await w.p.retirement()).retires_by, "disable");
|
||||
assert.equal((await w.p.retirement()).retired[0].access, "disabled");
|
||||
const before = w.created.length;
|
||||
await w.ask(["a", "b", "c"]);
|
||||
await until(() => w.created.length > before);
|
||||
assert.deepEqual(w.reenabled, ["a"]);
|
||||
assert.deepEqual(w.removed, []);
|
||||
} finally {
|
||||
w.done();
|
||||
}
|
||||
});
|
||||
|
||||
test("four of four waits for a person: approve the exact set retires, another set is refused", async () => {
|
||||
const w = await world(["a", "b", "c", "d"]);
|
||||
try {
|
||||
|
||||
+23
-11
@@ -31,7 +31,7 @@ test("module tools register and collect, a thrower is skipped not fatal", () =>
|
||||
assert.equal(broken?.tools.length, 0);
|
||||
});
|
||||
|
||||
test("provisioner creates each consumer with the mesh's login and password, removes on withdrawal", async () => {
|
||||
test("provisioner creates each consumer with the mesh's login and password; remove waits for a person's delete", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "prov-"));
|
||||
const created: { as: string; password: string; values: unknown }[] = [];
|
||||
const removed: string[] = [];
|
||||
@@ -57,7 +57,7 @@ test("provisioner creates each consumer with the mesh's login and password, remo
|
||||
removed.push(p.as);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 20 },
|
||||
{ receives, everyMs: 20, stableForMs: 0 },
|
||||
);
|
||||
|
||||
await waitFor(() => created.length === 1, 2000);
|
||||
@@ -67,10 +67,18 @@ test("provisioner creates each consumer with the mesh's login and password, remo
|
||||
assert.equal(created[0].password, "minted-pw");
|
||||
assert.deepEqual(created[0].values, { name: "webapp" });
|
||||
|
||||
// Withdraw: the mesh drops the consumer from the file → the harness removes it via the adapter.
|
||||
// The mesh drops the consumer: it is RETIRED, mark only — this adapter cannot disable, so remove is
|
||||
// NOT called (novox/hq ADR 0230); it keeps its access until a person deletes it.
|
||||
await writeFile(receives, doc([]));
|
||||
await waitFor(() => removed.length === 1, 2000);
|
||||
assert.equal(removed[0], "webapp-anchor");
|
||||
for (let i = 0; i < 200 && (await stop.retirement()).retired.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
|
||||
const r = await stop.retirement();
|
||||
assert.equal(r.retires_by, "mark-only");
|
||||
assert.deepEqual(r.retired.map((x) => [x.consumer, x.access]), [["webapp-anchor", "kept"]]);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
assert.deepEqual(removed, [], "remove was called to retire");
|
||||
// Only a person's delete reaches remove.
|
||||
await stop.deleteRetired("webapp-anchor", { why: "gone for good", confirm: "webapp-anchor" });
|
||||
assert.deepEqual(removed, ["webapp-anchor"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
@@ -103,7 +111,7 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
|
||||
removed.push(p.derived);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 20 },
|
||||
{ receives, everyMs: 20, stableForMs: 0 },
|
||||
);
|
||||
|
||||
await waitFor(() => created.length === 1, 2000);
|
||||
@@ -115,10 +123,12 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w
|
||||
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.
|
||||
// Retired, then deleted by a person: 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);
|
||||
for (let i = 0; i < 200 && (await stop.retirement()).retired.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(removed, []);
|
||||
await stop.deleteRetired("mesh_anchor_ncloud", { why: "unassigned", confirm: "mesh_anchor_ncloud" });
|
||||
assert.deepEqual(removed[0], { bucket: "mesh-anchor-files" });
|
||||
stop();
|
||||
});
|
||||
@@ -185,7 +195,7 @@ test("provisioner removes nothing while its contributions file cannot be used (n
|
||||
removed.push(p.as);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 20 },
|
||||
{ receives, everyMs: 20, stableForMs: 0 },
|
||||
);
|
||||
await waitFor(() => created.length === 2, 2000);
|
||||
|
||||
@@ -210,7 +220,9 @@ test("provisioner removes nothing while its contributions file cannot be used (n
|
||||
assert.notEqual((await stop.retirement()).waiting, null, "the retirement never waited for a person");
|
||||
assert.deepEqual(removed, [], "two of two consumers were retired without a person");
|
||||
await stop.approve(["mesh_anchor_mail", "mesh_anchor_forge"], { why: "both unassigned on purpose", by: "test" });
|
||||
assert.deepEqual([...removed].sort(), ["mesh_anchor_forge", "mesh_anchor_mail"]);
|
||||
// Retired mark-only: still nothing removed; that is a deletion, a person's further act.
|
||||
assert.deepEqual(removed, []);
|
||||
assert.deepEqual((await stop.retirement()).retired.map((x) => x.consumer).sort(), ["mesh_anchor_forge", "mesh_anchor_mail"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user