Provisioner asks the backend, not memory, whether a consumer is still there (hq issue 120) #7
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@novox/mesh-sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@novox/mesh-sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@novox/mesh-sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "The stable spine a Novox Mesh module's own code builds against.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface Provision {
|
||||
export interface Adapter {
|
||||
create(p: Provision): Promise<void>;
|
||||
remove(p: { readonly as: string }): 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
|
||||
* without persisting its users, a restore, a login removed by hand) is provisioned again instead
|
||||
* 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>;
|
||||
}
|
||||
|
||||
export interface ProvisionerOptions {
|
||||
@@ -43,6 +50,10 @@ export interface ProvisionerOptions {
|
||||
receives?: string;
|
||||
/** Reconcile interval in ms. Defaults to 5000. */
|
||||
everyMs?: number;
|
||||
/** How often, in ms, an adapter with `holds` is asked whether the backend still holds each
|
||||
* applied consumer. Defaults to 60000: slower than reconciling, because it reads the backend for
|
||||
* every consumer, and fast enough that a lost login is back within a minute. */
|
||||
verifyEveryMs?: number;
|
||||
}
|
||||
|
||||
/** One entry in the mesh's contributions file: a consumer the provider must serve. */
|
||||
@@ -62,6 +73,8 @@ interface Contribution {
|
||||
export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): () => void {
|
||||
const receives = opts.receives ?? envOrThrow("MESH_RECEIVES");
|
||||
const everyMs = opts.everyMs ?? 5000;
|
||||
const verifyEveryMs = opts.verifyEveryMs ?? 60_000;
|
||||
let verifiedAt = 0;
|
||||
|
||||
const applied = new Map<string, string>(); // login (`as`) -> hash of what was last applied
|
||||
let stopped = false;
|
||||
@@ -69,6 +82,9 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
async function reconcile(): Promise<void> {
|
||||
const given = await readContributions(receives, resource);
|
||||
const wantByAs = new Map(given.map((g) => [g.as, g]));
|
||||
// On this pass, ask the backend rather than memory whether each applied consumer is still there.
|
||||
const verifying = adapter.holds !== undefined && Date.now() - verifiedAt >= verifyEveryMs;
|
||||
if (verifying) verifiedAt = Date.now();
|
||||
|
||||
// Create or update every consumer whose login, password or values changed.
|
||||
for (const g of given) {
|
||||
@@ -85,9 +101,21 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
|
||||
continue;
|
||||
}
|
||||
const h = hash(g.as, password, g.values ?? {});
|
||||
if (applied.get(g.as) === h) continue;
|
||||
const p: Provision = { as: g.as, password, values: g.values ?? {}, at: g.at, consumer: g.node };
|
||||
if (applied.get(g.as) === h) {
|
||||
if (!verifying) continue;
|
||||
try {
|
||||
if (await adapter.holds!(p)) continue;
|
||||
// Said, because it means the backend lost something while nothing was looking.
|
||||
console.error(`[provisioner:${resource}] ${g.as}: the backend no longer holds it; applying again`);
|
||||
} catch (err) {
|
||||
// Unable to ask is not evidence of loss. Kept as applied, asked again next time.
|
||||
console.error(`[provisioner:${resource}] ${g.as}: could not check the backend, will ask again: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await adapter.create({ as: g.as, password, values: g.values ?? {}, at: g.at, consumer: g.node });
|
||||
await adapter.create(p);
|
||||
applied.set(g.as, h);
|
||||
} catch (err) {
|
||||
console.error(`[provisioner:${resource}] ${g.as}: create failed, will retry: ${err}`);
|
||||
|
||||
@@ -74,6 +74,99 @@ test("provisioner creates each consumer with the mesh's login and password, remo
|
||||
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");
|
||||
const receives = join(dir, "cache.json");
|
||||
await writeFile(
|
||||
receives,
|
||||
JSON.stringify({ contributions: 1, requirement: "cache", given: [{ from: "webapp", node: "anchor", as: "webapp-anchor", secret: join(dir, "webapp.secret") }] }),
|
||||
);
|
||||
|
||||
// A backend that forgets: what was created is held until it "restarts".
|
||||
const backend = new Set<string>();
|
||||
let creates = 0;
|
||||
let asked = 0;
|
||||
const stop = runProvisioner(
|
||||
"cache",
|
||||
{
|
||||
async create(p) {
|
||||
creates++;
|
||||
backend.add(`${p.as}:${p.password}`);
|
||||
},
|
||||
async remove(p) {
|
||||
for (const k of backend) if (k.startsWith(`${p.as}:`)) backend.delete(k);
|
||||
},
|
||||
async holds(p) {
|
||||
asked++;
|
||||
return backend.has(`${p.as}:${p.password}`);
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 10, verifyEveryMs: 30 },
|
||||
);
|
||||
|
||||
await waitFor(() => creates === 1, 2000);
|
||||
// While the backend holds it, asking changes nothing: no second create.
|
||||
await waitFor(() => asked >= 2, 2000);
|
||||
assert.equal(creates, 1);
|
||||
|
||||
// The backend restarts and forgets. The contributions did not change; only asking can notice.
|
||||
backend.clear();
|
||||
await waitFor(() => creates === 2, 2000);
|
||||
assert.ok(backend.has("webapp-anchor:minted-pw"));
|
||||
stop();
|
||||
|
||||
// An adapter without holds is trusted from memory, as before: a forgotten backend stays forgotten.
|
||||
const forgetful = new Set<string>();
|
||||
let plainCreates = 0;
|
||||
const stopPlain = runProvisioner(
|
||||
"cache",
|
||||
{
|
||||
async create(p) {
|
||||
plainCreates++;
|
||||
forgetful.add(p.as);
|
||||
},
|
||||
async remove() {},
|
||||
},
|
||||
{ receives, everyMs: 10, verifyEveryMs: 10 },
|
||||
);
|
||||
await waitFor(() => plainCreates === 1, 2000);
|
||||
forgetful.clear();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
assert.equal(plainCreates, 1);
|
||||
stopPlain();
|
||||
});
|
||||
|
||||
test("provisioner keeps a consumer applied when the backend cannot be asked", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "prov-unreachable-"));
|
||||
await writeFile(join(dir, "webapp.secret"), "minted-pw");
|
||||
const receives = join(dir, "cache.json");
|
||||
await writeFile(
|
||||
receives,
|
||||
JSON.stringify({ requirement: "cache", given: [{ as: "webapp-anchor", secret: join(dir, "webapp.secret") }] }),
|
||||
);
|
||||
let creates = 0;
|
||||
let asked = 0;
|
||||
const stop = runProvisioner(
|
||||
"cache",
|
||||
{
|
||||
async create() {
|
||||
creates++;
|
||||
},
|
||||
async remove() {},
|
||||
async holds() {
|
||||
asked++;
|
||||
throw new Error("connection refused");
|
||||
},
|
||||
},
|
||||
{ receives, everyMs: 10, verifyEveryMs: 20 },
|
||||
);
|
||||
await waitFor(() => asked >= 3, 2000);
|
||||
// Unable to ask is not evidence of loss: nothing is applied again.
|
||||
assert.equal(creates, 1);
|
||||
stop();
|
||||
});
|
||||
|
||||
test("modules can SERVE: a real async tool, loaded and invoked over the broker", async () => {
|
||||
resetTools();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user