@serfab/cadre-host 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -16
- package/dist/bin/host.js +41 -20
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +27 -4
- package/dist/donation/donation-service.js +57 -6
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +3 -1
- package/dist/donation/donation-store.js +5 -1
- package/dist/donation/donation-store.js.map +1 -1
- package/dist/donation/grant-service.d.ts +16 -7
- package/dist/donation/grant-service.js +57 -10
- package/dist/donation/grant-service.js.map +1 -1
- package/dist/donation/index.d.ts +2 -2
- package/dist/donation/index.js +1 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +29 -7
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/nat/address-resolver.d.ts +3 -2
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +1 -1
- package/dist/orchestrator/host-process-orchestrator.js +21 -11
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +6 -39
- package/dist/orchestrator/port-allocator.js +9 -89
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +19 -18
- package/dist/server/index.js +13 -15
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +15 -3
- package/dist/server/routes/grants-admin.js +33 -7
- package/dist/server/routes/grants-admin.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +13 -5
- package/dist/server/routes/nodes.js +58 -39
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/status.d.ts +13 -0
- package/dist/server/routes/status.js +2 -0
- package/dist/server/routes/status.js.map +1 -1
- package/dist/ui/assets/index-C6nELNfC.js +23 -0
- package/dist/ui/assets/index-C6nELNfC.js.map +1 -0
- package/dist/ui/assets/index-TtIYSeZO.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -4
- package/src/__tests__/global-setup.ts +58 -56
- package/src/__tests__/orchestrator-env-scrub.test.ts +12 -11
- package/src/__tests__/orchestrator-handle-contract.ts +134 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +6 -7
- package/src/__tests__/orchestrator-owner.test.ts +6 -13
- package/src/__tests__/orchestrator-pin-keys.test.ts +6 -7
- package/src/__tests__/orchestrator-ports.test.ts +5 -34
- package/src/__tests__/orchestrator-push.test.ts +6 -5
- package/src/__tests__/orchestrator-teardown.ts +65 -0
- package/src/__tests__/orchestrator.test.ts +23 -22
- package/src/auth/__tests__/trust-circle-integration.test.ts +1 -1
- package/src/bin/host.ts +56 -33
- package/src/donation/__tests__/donation-service.test.ts +38 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +19 -14
- package/src/donation/__tests__/fake-orchestrator.ts +21 -3
- package/src/donation/__tests__/grant-service.test.ts +2 -2
- package/src/donation/donation-service.ts +58 -6
- package/src/donation/donation-store.ts +176 -171
- package/src/donation/grant-service.ts +252 -200
- package/src/donation/index.ts +65 -64
- package/src/donation/types.ts +260 -244
- package/src/index.ts +2 -0
- package/src/nat/address-resolver.ts +3 -2
- package/src/orchestrator/host-process-orchestrator.ts +1260 -1251
- package/src/orchestrator/port-allocator.ts +16 -91
- package/src/server/__tests__/fakes.ts +44 -0
- package/src/server/__tests__/grants-admin-route.test.ts +171 -73
- package/src/server/__tests__/nodes-route.test.ts +49 -25
- package/src/server/__tests__/publishers.test.ts +5 -41
- package/src/server/__tests__/server.smoke.test.ts +7 -37
- package/src/server/__tests__/status-route.test.ts +8 -21
- package/src/server/__tests__/strands-route.test.ts +3 -2
- package/src/server/events/types.ts +24 -23
- package/src/server/index.ts +267 -267
- package/src/server/routes/grants-admin.ts +74 -41
- package/src/server/routes/nodes.ts +61 -43
- package/src/server/routes/status.ts +16 -0
- package/dist/donation/__tests__/fake-orchestrator.d.ts +0 -103
- package/dist/donation/__tests__/fake-orchestrator.js +0 -180
- package/dist/donation/__tests__/fake-orchestrator.js.map +0 -1
- package/dist/ui/assets/index-BVoqLjyX.css +0 -1
- package/dist/ui/assets/index-CEoNRwM_.js +0 -17
- package/dist/ui/assets/index-CEoNRwM_.js.map +0 -1
|
@@ -19,6 +19,8 @@ import { decodeDockerId, encodeDockerId, type NodePorts } from '../orchestrator/
|
|
|
19
19
|
import { StateStore } from '../orchestrator/state-store.js';
|
|
20
20
|
import { isPidAlive } from '../orchestrator/pid-liveness.js';
|
|
21
21
|
import { loadIdentity } from '../installer/identity.js';
|
|
22
|
+
import { describeHandleContract } from './orchestrator-handle-contract.js';
|
|
23
|
+
import { removeAllNodes } from './orchestrator-teardown.js';
|
|
22
24
|
|
|
23
25
|
const FAKE_CHILD = `
|
|
24
26
|
import fs from 'node:fs';
|
|
@@ -76,15 +78,12 @@ beforeEach(() => {
|
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
afterEach(async () => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
81
|
+
try {
|
|
82
|
+
await removeAllNodes(orchestrators);
|
|
83
|
+
} finally {
|
|
84
|
+
await sleep(50);
|
|
85
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
84
86
|
}
|
|
85
|
-
orchestrators.length = 0;
|
|
86
|
-
await sleep(50);
|
|
87
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
88
87
|
});
|
|
89
88
|
|
|
90
89
|
function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
|
|
@@ -103,12 +102,6 @@ function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostPr
|
|
|
103
102
|
return orch;
|
|
104
103
|
}
|
|
105
104
|
|
|
106
|
-
function listDockerIds(orch: HostProcessOrchestrator): string[] {
|
|
107
|
-
// Use the state file as the source of truth since handles map is private.
|
|
108
|
-
const state = new StateStore((orch as unknown as { rootDir: string }).rootDir);
|
|
109
|
-
return state.load().handles.map((h) => h.dockerId);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
105
|
async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
|
|
113
106
|
const deadline = Date.now() + timeoutMs;
|
|
114
107
|
while (Date.now() < deadline) {
|
|
@@ -131,6 +124,21 @@ function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transac
|
|
|
131
124
|
};
|
|
132
125
|
}
|
|
133
126
|
|
|
127
|
+
/** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
|
|
128
|
+
function sabotageWorkdir(workdir: string): string {
|
|
129
|
+
const storage = join(workdir, 'storage');
|
|
130
|
+
rmSync(storage, { recursive: true, force: true });
|
|
131
|
+
writeFileSync(storage, 'not-a-directory', 'utf8');
|
|
132
|
+
return storage;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describeHandleContract<HostProcessOrchestrator>('HostProcessOrchestrator', {
|
|
136
|
+
make: () => makeOrchestrator(),
|
|
137
|
+
request: (containerId) => makeRequest(containerId),
|
|
138
|
+
started: (orch, dockerId) => waitFor(() => orch.isRunning(dockerId)),
|
|
139
|
+
failNextCreate: (orch, containerId) => { sabotageWorkdir(orch.getNode(containerId)!.workdir); },
|
|
140
|
+
});
|
|
141
|
+
|
|
134
142
|
describe('HostProcessOrchestrator.createContainer', () => {
|
|
135
143
|
it('spawns a live child and persists handle, ports, and endpoints', async () => {
|
|
136
144
|
const orch = makeOrchestrator();
|
|
@@ -265,14 +273,6 @@ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
|
|
|
265
273
|
* spawn.
|
|
266
274
|
*/
|
|
267
275
|
describe('HostProcessOrchestrator failed launch', () => {
|
|
268
|
-
/** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
|
|
269
|
-
function sabotageWorkdir(workdir: string): string {
|
|
270
|
-
const storage = join(workdir, 'storage');
|
|
271
|
-
rmSync(storage, { recursive: true, force: true });
|
|
272
|
-
writeFileSync(storage, 'not-a-directory', 'utf8');
|
|
273
|
-
return storage;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
276
|
it('keeps the prior handle addressable, so a later terminate still reclaims the workdir', async () => {
|
|
277
277
|
const orch = makeOrchestrator();
|
|
278
278
|
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
@@ -1002,6 +1002,7 @@ describe('child survives orchestrator exit', () => {
|
|
|
1002
1002
|
const sizeB = statSync(handle.logPath).size;
|
|
1003
1003
|
expect(sizeB).toBeGreaterThan(sizeA);
|
|
1004
1004
|
} finally {
|
|
1005
|
+
// Started by the helper process, so no orchestrator here holds it: the one child removeAllNodes cannot see.
|
|
1005
1006
|
try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
1006
1007
|
try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
|
|
1007
1008
|
}
|
|
@@ -58,7 +58,7 @@ describe('TrustCircleService — real CadreNode integration', () => {
|
|
|
58
58
|
expect(db).not.toBeNull();
|
|
59
59
|
await db!.insertOwnerKey(hostOwnerPublicKey);
|
|
60
60
|
|
|
61
|
-
host.initializeSeedBootstrap(hostOwnerPrivateKey);
|
|
61
|
+
await host.initializeSeedBootstrap(hostOwnerPrivateKey);
|
|
62
62
|
|
|
63
63
|
// The owner writes its own CadrePeer row up-front (the implement ticket's
|
|
64
64
|
// CLI change). After this the host's own peerId is a member, so it shows up
|
package/src/bin/host.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
DONATION_AWAITING_SEED_TTL_MS,
|
|
46
46
|
DONATION_PROVISIONING_TTL_MS,
|
|
47
47
|
DONATION_REAP_SWEEP_MS,
|
|
48
|
+
type GrantListing,
|
|
48
49
|
} from '../donation/index.js';
|
|
49
50
|
import { NatService } from '../nat/index.js';
|
|
50
51
|
import { createSecretsStore } from '../nat/secrets/index.js';
|
|
@@ -57,7 +58,7 @@ import {
|
|
|
57
58
|
} from '../push/index.js';
|
|
58
59
|
import { StrandService } from '../strands/index.js';
|
|
59
60
|
import { OwnerNodeClient } from '../owner/index.js';
|
|
60
|
-
import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
|
|
61
|
+
import { createLocalUiServer, HostSettingsStore, type FounderServices } from '../server/index.js';
|
|
61
62
|
import { openBrowser } from '../installer/browser.js';
|
|
62
63
|
|
|
63
64
|
const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
|
|
@@ -353,9 +354,7 @@ program
|
|
|
353
354
|
// (they 404). Per-donated-node WAN reachability is deferred to
|
|
354
355
|
// backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
|
|
355
356
|
// loopback-only — nothing for NatService to map without an owner node.
|
|
356
|
-
let
|
|
357
|
-
let natService: NatService | undefined;
|
|
358
|
-
let strandService: StrandService | undefined;
|
|
357
|
+
let founder: FounderServices | undefined;
|
|
359
358
|
if (hostOwnsCadre(cfg)) {
|
|
360
359
|
// Spawn the owner node. Best-effort: a spawn failure leaves the
|
|
361
360
|
// management API up (trust-circle listing degrades to local labels,
|
|
@@ -376,19 +375,19 @@ program
|
|
|
376
375
|
const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
|
|
377
376
|
|
|
378
377
|
const trustCircleStore = new TrustCircleStore(cfg.dataDir);
|
|
379
|
-
trustCircle = new TrustCircleService({
|
|
378
|
+
const trustCircle = new TrustCircleService({
|
|
380
379
|
cadreNode: owner,
|
|
381
380
|
store: trustCircleStore,
|
|
382
381
|
});
|
|
383
382
|
|
|
384
|
-
natService = new NatService({
|
|
383
|
+
const natService = new NatService({
|
|
385
384
|
rootDir: cfg.dataDir,
|
|
386
385
|
cadreNode: owner,
|
|
387
386
|
});
|
|
388
387
|
|
|
389
388
|
// Strand management is founder-only for the same reason as the trust
|
|
390
389
|
// circle: it asks the owner node, and donor-only mode has none.
|
|
391
|
-
strandService = new StrandService({ cadreNode: owner });
|
|
390
|
+
const strandService = new StrandService({ cadreNode: owner });
|
|
392
391
|
|
|
393
392
|
// Push NAT-resolved invite addresses to the node on every NAT change.
|
|
394
393
|
// NatService.start() also fires this once as an initial push, retried
|
|
@@ -422,6 +421,8 @@ program
|
|
|
422
421
|
} catch (err) {
|
|
423
422
|
console.error(`self trust-circle label failed: ${(err as Error).message}`);
|
|
424
423
|
}
|
|
424
|
+
|
|
425
|
+
founder = { trustCircle, nat: natService, strands: strandService };
|
|
425
426
|
} else {
|
|
426
427
|
// Donor-only. If ownCadre was toggled off after a prior founder run,
|
|
427
428
|
// orchestrator.init() re-attaches the still-running owner child (it would
|
|
@@ -442,9 +443,7 @@ program
|
|
|
442
443
|
uiPort: cfg.uiPort,
|
|
443
444
|
dataDir: cfg.dataDir,
|
|
444
445
|
orchestrator,
|
|
445
|
-
...(
|
|
446
|
-
...(natService ? { nat: natService } : {}),
|
|
447
|
-
...(strandService ? { strands: strandService } : {}),
|
|
446
|
+
...(founder ? { founder } : {}),
|
|
448
447
|
update: updateService,
|
|
449
448
|
grants: grantService,
|
|
450
449
|
donations: donationService,
|
|
@@ -460,7 +459,7 @@ program
|
|
|
460
459
|
clearInterval(reapTimer);
|
|
461
460
|
donationSupervisor.stop();
|
|
462
461
|
try { await server.stop(); } catch { /* ignore */ }
|
|
463
|
-
try { await
|
|
462
|
+
try { await founder?.nat.stop(); } catch { /* ignore */ }
|
|
464
463
|
try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
|
|
465
464
|
updateService.stop();
|
|
466
465
|
console.log('cadre-host stopped.');
|
|
@@ -730,8 +729,7 @@ trust
|
|
|
730
729
|
// A grant token lets one grantee (friend/family) present a Bearer credential to
|
|
731
730
|
// ask this host to donate cadre nodes, up to a per-grantee cap. These commands
|
|
732
731
|
// are thin HTTP clients of the loopback `/grants-admin` admin surface — no
|
|
733
|
-
// bearer (same-machine admin), same posture as `invite` / `trust`.
|
|
734
|
-
// grantee-facing provisioning surface lands in the donation-service ticket.
|
|
732
|
+
// bearer (same-machine admin), same posture as `invite` / `trust`.
|
|
735
733
|
|
|
736
734
|
const grant = program
|
|
737
735
|
.command('grant')
|
|
@@ -819,16 +817,14 @@ grant
|
|
|
819
817
|
process.exit(1);
|
|
820
818
|
return;
|
|
821
819
|
}
|
|
822
|
-
const body = await response.json() as {
|
|
823
|
-
grants: Array<{ token: string; label: string; maxNodes: number; expiresAt?: string; revokedAt?: string }>;
|
|
824
|
-
};
|
|
820
|
+
const body = await response.json() as { grants: GrantListing[] };
|
|
825
821
|
console.log('Grants:');
|
|
826
822
|
if (body.grants.length === 0) {
|
|
827
823
|
console.log(' (none)');
|
|
828
824
|
} else {
|
|
829
825
|
for (const g of body.grants) {
|
|
830
826
|
const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
|
|
831
|
-
console.log(` ${g.token} ${g.label} max=${g.maxNodes}${state}`);
|
|
827
|
+
console.log(` ${g.token} ${g.label} live=${g.liveNodes} max=${g.maxNodes}${state}`);
|
|
832
828
|
}
|
|
833
829
|
}
|
|
834
830
|
process.exit(0);
|
|
@@ -836,30 +832,57 @@ grant
|
|
|
836
832
|
|
|
837
833
|
grant
|
|
838
834
|
.command('revoke')
|
|
839
|
-
.description('Revoke a grant token (blocks future requests
|
|
835
|
+
.description('Revoke a grant token (blocks future requests) and shut down the nodes donated under it')
|
|
840
836
|
.argument('<token>', 'Grant token to revoke')
|
|
837
|
+
.option('--keep-nodes', 'Leave the nodes already donated under this grant running')
|
|
841
838
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
842
839
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
843
|
-
.action(async (token: string, opts: { port: string; host: string }) => {
|
|
840
|
+
.action(async (token: string, opts: { keepNodes?: boolean; port: string; host: string }) => {
|
|
844
841
|
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
} catch (err) {
|
|
849
|
-
console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
|
|
850
|
-
process.exit(2);
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
if (!response.ok) {
|
|
854
|
-
const text = await response.text().catch(() => '');
|
|
855
|
-
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
856
|
-
process.exit(1);
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
842
|
+
const query = opts.keepNodes ? '?keepNodes=true' : '';
|
|
843
|
+
const response = await adminDelete(base, `/grants-admin/${encodeURIComponent(token)}${query}`);
|
|
844
|
+
const body = await response.json() as { terminated?: string[] };
|
|
859
845
|
console.log(`revoked grant: ${token}`);
|
|
846
|
+
console.log(opts.keepNodes
|
|
847
|
+
? 'existing donated nodes left running'
|
|
848
|
+
: `terminated ${body.terminated?.length ?? 0} donated node(s)`);
|
|
860
849
|
process.exit(0);
|
|
861
850
|
});
|
|
862
851
|
|
|
852
|
+
grant
|
|
853
|
+
.command('terminate')
|
|
854
|
+
.description('Shut down one donated node (the id shown on the Nodes page)')
|
|
855
|
+
.argument('<donation-id>', 'Donation id (grn_…) of the node to shut down')
|
|
856
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
857
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
858
|
+
.action(async (id: string, opts: { port: string; host: string }) => {
|
|
859
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
860
|
+
await adminDelete(base, `/grants-admin/donations/${encodeURIComponent(id)}`);
|
|
861
|
+
console.log(`terminated donated node: ${id}`);
|
|
862
|
+
process.exit(0);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* DELETE against the loopback admin surface, exiting the process on failure —
|
|
867
|
+
* 2 when cadre-host is unreachable, 1 on a non-OK response — so callers only
|
|
868
|
+
* ever see an OK response.
|
|
869
|
+
*/
|
|
870
|
+
async function adminDelete(base: string, path: string): Promise<Response> {
|
|
871
|
+
let response: Response;
|
|
872
|
+
try {
|
|
873
|
+
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
874
|
+
} catch (err) {
|
|
875
|
+
console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
|
|
876
|
+
process.exit(2);
|
|
877
|
+
}
|
|
878
|
+
if (!response.ok) {
|
|
879
|
+
const text = await response.text().catch(() => '');
|
|
880
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
return response;
|
|
884
|
+
}
|
|
885
|
+
|
|
863
886
|
// ============================================================================
|
|
864
887
|
// nat subcommands
|
|
865
888
|
// ============================================================================
|
|
@@ -529,6 +529,8 @@ describe('DonationService.applySeed', () => {
|
|
|
529
529
|
});
|
|
530
530
|
});
|
|
531
531
|
|
|
532
|
+
// The orchestrator refuses to re-spawn a container whose child is still running, so
|
|
533
|
+
// each case that expects a spawn has the first child (`dock_1`) go down first.
|
|
532
534
|
describe('DonationService.respawn', () => {
|
|
533
535
|
it('replays the persisted spawn inputs, swaps the handles, and leaves status alone', async () => {
|
|
534
536
|
const orch = new FakeOrchestrator();
|
|
@@ -540,6 +542,7 @@ describe('DonationService.respawn', () => {
|
|
|
540
542
|
// Pretend the borrower seeded it, then the child died.
|
|
541
543
|
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
542
544
|
|
|
545
|
+
orch.crash('dock_1');
|
|
543
546
|
const result = await svc.respawn(provisioned.id);
|
|
544
547
|
|
|
545
548
|
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
@@ -573,6 +576,7 @@ describe('DonationService.respawn', () => {
|
|
|
573
576
|
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
574
577
|
|
|
575
578
|
const provisioned = await svc.provision(baseRequest(token));
|
|
579
|
+
orch.crash('dock_1');
|
|
576
580
|
const result = await svc.respawn(provisioned.id);
|
|
577
581
|
|
|
578
582
|
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
@@ -615,6 +619,7 @@ describe('DonationService.respawn', () => {
|
|
|
615
619
|
const provisioned = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
|
|
616
620
|
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
617
621
|
|
|
622
|
+
orch.crash('dock_1');
|
|
618
623
|
const result = await svc.respawn(provisioned.id);
|
|
619
624
|
|
|
620
625
|
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
@@ -710,6 +715,7 @@ describe('DonationService.respawn', () => {
|
|
|
710
715
|
const provisioned = await svc.provision(baseRequest(token));
|
|
711
716
|
store.failNextPut = true;
|
|
712
717
|
|
|
718
|
+
orch.crash('dock_1');
|
|
713
719
|
await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
714
720
|
|
|
715
721
|
// Stopped, not removed: `removeContainer` deletes the workdir, and the
|
|
@@ -780,6 +786,7 @@ describe('DonationService.respawn', () => {
|
|
|
780
786
|
let terminated: Promise<void> | undefined;
|
|
781
787
|
orch.onSpawned = () => { terminated ??= svc.terminate(provisioned.id); };
|
|
782
788
|
|
|
789
|
+
orch.crash('dock_1');
|
|
783
790
|
const result = await svc.respawn(provisioned.id);
|
|
784
791
|
await terminated;
|
|
785
792
|
|
|
@@ -849,6 +856,7 @@ describe('DonationService.respawn', () => {
|
|
|
849
856
|
store.put({ ...store.get(provisioned.id)!, status: 'error', error: 'gave up' });
|
|
850
857
|
};
|
|
851
858
|
|
|
859
|
+
orch.crash('dock_1');
|
|
852
860
|
const result = await svc.respawn(provisioned.id);
|
|
853
861
|
|
|
854
862
|
expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
|
|
@@ -870,6 +878,7 @@ describe('DonationService.respawn', () => {
|
|
|
870
878
|
orch.createDelayMs = 20;
|
|
871
879
|
orch.onCreate = () => { store.remove(provisioned.id); };
|
|
872
880
|
|
|
881
|
+
orch.crash('dock_1');
|
|
873
882
|
const result = await svc.respawn(provisioned.id);
|
|
874
883
|
|
|
875
884
|
// No row to protect, so nothing is written back and the child is fully
|
|
@@ -956,6 +965,35 @@ describe('DonationService.terminate', () => {
|
|
|
956
965
|
});
|
|
957
966
|
});
|
|
958
967
|
|
|
968
|
+
describe('DonationService.terminateGrant', () => {
|
|
969
|
+
it('terminates every unterminated donation under the grant, error included, and leaves other grants alone', async () => {
|
|
970
|
+
const orch = new FakeOrchestrator();
|
|
971
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
972
|
+
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
973
|
+
const otherToken = grants.issue({ label: 'Bob' }).token;
|
|
974
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
975
|
+
|
|
976
|
+
const seeded = await svc.provision(baseRequest(token)); // dock_1
|
|
977
|
+
store.put({ ...store.get(seeded.id)!, status: 'seeded' });
|
|
978
|
+
// A loan the supervisor gave up on keeps its workdir for a later terminate —
|
|
979
|
+
// which, once the grant is revoked, only this teardown can make.
|
|
980
|
+
const gaveUp = await svc.provision(baseRequest(token)); // dock_2
|
|
981
|
+
store.put({ ...store.get(gaveUp.id)!, status: 'error', error: 'respawn gave up' });
|
|
982
|
+
const ended = await svc.provision(baseRequest(token)); // dock_3
|
|
983
|
+
await svc.terminate(ended.id);
|
|
984
|
+
const bobs = await svc.provision(baseRequest(otherToken)); // dock_4
|
|
985
|
+
|
|
986
|
+
grants.revoke(token);
|
|
987
|
+
const terminated = await svc.terminateGrant(token);
|
|
988
|
+
|
|
989
|
+
expect(terminated.sort()).toEqual([seeded.id, gaveUp.id].sort());
|
|
990
|
+
expect(store.get(seeded.id)?.status).toBe('terminated');
|
|
991
|
+
expect(store.get(gaveUp.id)?.status).toBe('terminated');
|
|
992
|
+
expect(store.get(bobs.id)?.status).toBe('awaiting_seed');
|
|
993
|
+
expect(orch.removed.sort()).toEqual(['dock_1', 'dock_2', 'dock_3']);
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
|
|
959
997
|
describe('DonationService.reapStaleAwaitingSeed', () => {
|
|
960
998
|
it('terminates awaiting_seed donations past the TTL and leaves fresh ones alone', async () => {
|
|
961
999
|
const orch = new FakeOrchestrator();
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* from `HostProcessOrchestrator`. The donation suites lean on these semantics to
|
|
4
4
|
* express races that only exist because the real class drops and rejects
|
|
5
5
|
* handles, so this file exists to stop a later relaxation of the fake from
|
|
6
|
-
* quietly turning one of those tests green.
|
|
7
|
-
*
|
|
6
|
+
* quietly turning one of those tests green.
|
|
7
|
+
*
|
|
8
|
+
* The rules the fake shares with the real class live in
|
|
9
|
+
* `src/__tests__/orchestrator-handle-contract.ts` and run against both — here
|
|
10
|
+
* and in `src/__tests__/orchestrator.test.ts` — so a disagreement between the
|
|
11
|
+
* two fails a test. The cases in this file add what only the fake has: its
|
|
12
|
+
* recording arrays (`stopped`, `removed`, `reclaimedWorkdirs`) and its hooks.
|
|
13
|
+
* Each names the real behaviour it mirrors.
|
|
8
14
|
*/
|
|
9
15
|
|
|
10
16
|
import { describe, expect, it } from 'vitest';
|
|
11
17
|
|
|
12
18
|
import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
|
|
13
19
|
|
|
20
|
+
import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js';
|
|
14
21
|
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
15
22
|
|
|
16
23
|
const request = (containerId: string): OrchestratorCreateRequest => ({
|
|
@@ -20,10 +27,19 @@ const request = (containerId: string): OrchestratorCreateRequest => ({
|
|
|
20
27
|
profile: 'storage',
|
|
21
28
|
});
|
|
22
29
|
|
|
30
|
+
describeHandleContract<FakeOrchestrator>('FakeOrchestrator', {
|
|
31
|
+
make: () => new FakeOrchestrator(),
|
|
32
|
+
request,
|
|
33
|
+
// No process to wait for: a fake child is running as soon as it exists.
|
|
34
|
+
started: () => Promise.resolve(),
|
|
35
|
+
failNextCreate: (orch) => { orch.failCreate = true; },
|
|
36
|
+
});
|
|
37
|
+
|
|
23
38
|
describe('FakeOrchestrator handle lifecycle', () => {
|
|
24
39
|
it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
|
|
25
40
|
const orch = new FakeOrchestrator();
|
|
26
41
|
const first = await orch.createContainer(request('grn_1'));
|
|
42
|
+
orch.crash(first.dockerId);
|
|
27
43
|
const second = await orch.createContainer(request('grn_1'));
|
|
28
44
|
|
|
29
45
|
await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
|
|
@@ -39,18 +55,6 @@ describe('FakeOrchestrator handle lifecycle', () => {
|
|
|
39
55
|
expect(orch.stopped).toEqual([second.dockerId]);
|
|
40
56
|
});
|
|
41
57
|
|
|
42
|
-
it('drops only the re-spawned container, leaving another container untouched', async () => {
|
|
43
|
-
const orch = new FakeOrchestrator();
|
|
44
|
-
const other = await orch.createContainer(request('grn_2'));
|
|
45
|
-
await orch.createContainer(request('grn_1'));
|
|
46
|
-
await orch.createContainer(request('grn_1'));
|
|
47
|
-
|
|
48
|
-
// The real drop filters on containerId; a fake that cleared the whole map
|
|
49
|
-
// would still pass every same-container case above.
|
|
50
|
-
await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
|
|
51
|
-
expect(orch.resolveDockerId('grn_2')).toBe(other.dockerId);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
58
|
it('keeps the prior handle live across the whole pre-drop await window', async () => {
|
|
55
59
|
const orch = new FakeOrchestrator();
|
|
56
60
|
const first = await orch.createContainer(request('grn_1'));
|
|
@@ -153,6 +157,7 @@ describe('FakeOrchestrator handle lifecycle', () => {
|
|
|
153
157
|
it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
|
|
154
158
|
const orch = new FakeOrchestrator();
|
|
155
159
|
const first = await orch.createContainer(request('grn_1'));
|
|
160
|
+
orch.crash(first.dockerId);
|
|
156
161
|
|
|
157
162
|
let observed: { spawned: string; resolves: string | undefined } | undefined;
|
|
158
163
|
orch.onSpawned = (dockerId) => {
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* unique spawn results, and tracks per-`dockerId` liveness so a test can crash
|
|
5
5
|
* one node and let the supervisor observe exactly that one as down.
|
|
6
6
|
*
|
|
7
|
-
* This file holds no tests of its own
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
7
|
+
* This file holds no tests of its own. The handle rules it shares with
|
|
8
|
+
* `HostProcessOrchestrator` live in `src/__tests__/orchestrator-handle-contract.ts`
|
|
9
|
+
* and run against both classes, so the two cannot come to disagree unnoticed.
|
|
10
|
+
* The sibling `fake-orchestrator.test.ts` runs that contract and pins what only
|
|
11
|
+
* the fake has (its recording arrays and hooks). Both exist so a future agent
|
|
12
|
+
* cannot make a failing donation test go green by relaxing the fake.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import type {
|
|
@@ -39,6 +42,9 @@ interface FakeChild {
|
|
|
39
42
|
* resolving the moment its container re-spawns;
|
|
40
43
|
* - a **failed** `createContainer` leaves those handles exactly as it found them
|
|
41
44
|
* (the real `restoreDroppedHandles`);
|
|
45
|
+
* - `createContainer` refuses a container whose previous child is still running
|
|
46
|
+
* (the real `refuseRespawnOverLiveChild`), so a test re-spawns only after a
|
|
47
|
+
* `stopContainer` or a {@link FakeOrchestrator.crash};
|
|
42
48
|
* - `stopContainer` / `removeContainer` throw `Container not found: <dockerId>`
|
|
43
49
|
* for a handle this orchestrator no longer knows (the real `requireHandle`);
|
|
44
50
|
* - `reclaimWorkdir` refuses a containerId that still resolves to a live
|
|
@@ -94,6 +100,9 @@ export class FakeOrchestrator implements Orchestrator {
|
|
|
94
100
|
this.onCreate?.(request);
|
|
95
101
|
if (this.createDelayMs) await sleep(this.createDelayMs);
|
|
96
102
|
if (this.failCreate) throw new Error('spawn boom');
|
|
103
|
+
// After the delay, as in the real class, where the check is the last `await`
|
|
104
|
+
// before the drop: a stop that lands during the spawn window is seen here.
|
|
105
|
+
this.refuseRespawnOverLiveChild(request.containerId);
|
|
97
106
|
// Drop the prior handles for this container, mirroring `dropStaleHandle`.
|
|
98
107
|
// Success-only, and placed here on purpose: the real drop happens after the
|
|
99
108
|
// spawn's every `await` and cannot fail afterwards, and a create that throws
|
|
@@ -203,6 +212,15 @@ export class FakeOrchestrator implements Orchestrator {
|
|
|
203
212
|
for (const listener of this.listeners) listener(info);
|
|
204
213
|
}
|
|
205
214
|
|
|
215
|
+
/** Mirrors the real method of the same name for a child this process spawned. */
|
|
216
|
+
private refuseRespawnOverLiveChild(containerId: string): void {
|
|
217
|
+
for (const child of this.children.values()) {
|
|
218
|
+
if (child.containerId === containerId && child.running) {
|
|
219
|
+
throw new Error(`container ${containerId} is still running`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
/** Mirrors `HostProcessOrchestrator.requireHandle` — unknown handles throw. */
|
|
207
225
|
private requireChild(dockerId: string): FakeChild {
|
|
208
226
|
const child = this.children.get(dockerId);
|
|
@@ -194,7 +194,7 @@ describe('createGrantAdminHandlers', () => {
|
|
|
194
194
|
let listed = await handlers.listGrants();
|
|
195
195
|
expect(listed.grants.map(g => g.token)).toContain(issued.grant.token);
|
|
196
196
|
|
|
197
|
-
await handlers.deleteGrant(issued.grant.token);
|
|
197
|
+
await handlers.deleteGrant(issued.grant.token, { keepNodes: false });
|
|
198
198
|
listed = await handlers.listGrants();
|
|
199
199
|
// Revocation marks, not removes — the grant is still listed but revoked.
|
|
200
200
|
expect(listed.grants.find(g => g.token === issued.grant.token)?.revokedAt).toBeTruthy();
|
|
@@ -208,6 +208,6 @@ describe('createGrantAdminHandlers', () => {
|
|
|
208
208
|
|
|
209
209
|
it('deleteGrant surfaces not_found for an unknown token', async () => {
|
|
210
210
|
const handlers = createGrantAdminHandlers(service);
|
|
211
|
-
await expect(handlers.deleteGrant('nope')).rejects.toMatchObject({ code: 'not_found' });
|
|
211
|
+
await expect(handlers.deleteGrant('nope', { keepNodes: false })).rejects.toMatchObject({ code: 'not_found' });
|
|
212
212
|
});
|
|
213
213
|
});
|
|
@@ -227,14 +227,16 @@ export class DonationService {
|
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
229
|
* Per-grant-token serialization tail. `provision` chains onto the prior
|
|
230
|
-
*
|
|
230
|
+
* operation for the same token so the quota check and record-create are
|
|
231
231
|
* atomic — two concurrent requests at `count = maxNodes - 1` can't both pass.
|
|
232
|
+
* `terminateGrant` chains onto it too, so a revoke's teardown cannot miss a
|
|
233
|
+
* record that a provision already queued for the grant is about to write.
|
|
232
234
|
*
|
|
233
235
|
* NOTE: one entry per distinct token, never evicted. Fine at household scale
|
|
234
236
|
* (a handful of grants); if grant counts ever grow large, evict the tail once
|
|
235
237
|
* it resolves.
|
|
236
238
|
*/
|
|
237
|
-
private readonly
|
|
239
|
+
private readonly grantTail = new Map<string, Promise<void>>();
|
|
238
240
|
|
|
239
241
|
constructor(opts: DonationServiceOptions) {
|
|
240
242
|
this.orchestrator = opts.orchestrator;
|
|
@@ -693,6 +695,46 @@ export class DonationService {
|
|
|
693
695
|
log('donation %s terminated', id);
|
|
694
696
|
}
|
|
695
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Terminate every donation under one grant that is not already `terminated` —
|
|
700
|
+
* the teardown behind an admin revoke. Returns the ids it terminated.
|
|
701
|
+
*
|
|
702
|
+
* Includes `error` records on purpose: the supervisor's give-up keeps such a
|
|
703
|
+
* record's workdir (its identity key) for a later `terminate`, and once the
|
|
704
|
+
* grant is revoked the grantee can no longer make that call, so this is the
|
|
705
|
+
* last thing that can reclaim it.
|
|
706
|
+
*
|
|
707
|
+
* Serialized with `provision` on the same per-grant queue. The caller revokes
|
|
708
|
+
* the grant first, and `provisionLocked` re-validates the grant inside that
|
|
709
|
+
* queue, so a provision queued ahead of this call finishes and is then
|
|
710
|
+
* terminated here, while one queued behind it is refused as revoked.
|
|
711
|
+
*
|
|
712
|
+
* Idempotent, and best-effort per record like the reap sweeps: a failed
|
|
713
|
+
* terminate is logged and the loop continues, and running it again terminates
|
|
714
|
+
* whatever is still left.
|
|
715
|
+
*/
|
|
716
|
+
async terminateGrant(token: string): Promise<string[]> {
|
|
717
|
+
return this.serializeByGrant(token, () => this.terminateGrantLocked(token));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async terminateGrantLocked(token: string): Promise<string[]> {
|
|
721
|
+
const candidates = this.store.listByGrant(token).filter(isUnterminated);
|
|
722
|
+
const terminated: string[] = [];
|
|
723
|
+
for (const donation of candidates) {
|
|
724
|
+
try {
|
|
725
|
+
// Re-read: every `terminate` awaits, so from the second record on the
|
|
726
|
+
// candidate list is a stale snapshot (see `reapStaleAwaitingSeed`).
|
|
727
|
+
if (!isUnterminated(this.store.get(donation.id))) continue;
|
|
728
|
+
await this.terminate(donation.id);
|
|
729
|
+
terminated.push(donation.id);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
log('failed to terminate donation %s of grant %s: %s', donation.id, token, errorMessage(err));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
log('terminated %d donation(s) under grant %s', terminated.length, token);
|
|
735
|
+
return terminated;
|
|
736
|
+
}
|
|
737
|
+
|
|
696
738
|
/**
|
|
697
739
|
* Auto-terminate donations still `awaiting_seed` past `ttlMs` — a requester
|
|
698
740
|
* that provisioned a node but never presented a seed leaves an orphaned child
|
|
@@ -884,15 +926,16 @@ export class DonationService {
|
|
|
884
926
|
}
|
|
885
927
|
|
|
886
928
|
/**
|
|
887
|
-
* Run `fn` after any in-flight provision for the same grant
|
|
888
|
-
* quota check + record-create pair is atomic per grant
|
|
929
|
+
* Run `fn` after any in-flight provision or grant teardown for the same grant
|
|
930
|
+
* token, so the quota check + record-create pair is atomic per grant and a
|
|
931
|
+
* teardown sees every record a queued provision writes.
|
|
889
932
|
*/
|
|
890
933
|
private async serializeByGrant<T>(token: string, fn: () => Promise<T>): Promise<T> {
|
|
891
|
-
const prior = this.
|
|
934
|
+
const prior = this.grantTail.get(token) ?? Promise.resolve();
|
|
892
935
|
const run = prior.then(fn, fn);
|
|
893
936
|
// The stored tail swallows outcomes — a rejected provision must not reject
|
|
894
937
|
// the next caller's wait, only defer it.
|
|
895
|
-
this.
|
|
938
|
+
this.grantTail.set(token, run.then(() => undefined, () => undefined));
|
|
896
939
|
return run;
|
|
897
940
|
}
|
|
898
941
|
}
|
|
@@ -950,6 +993,15 @@ function denialToError(reason: GrantDenyReason | undefined): DonationError {
|
|
|
950
993
|
}
|
|
951
994
|
}
|
|
952
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Whether a record still needs a grant teardown. Stated once and applied twice
|
|
998
|
+
* in {@link DonationService.terminateGrant} — on the candidate list, and again
|
|
999
|
+
* per record before terminating it.
|
|
1000
|
+
*/
|
|
1001
|
+
function isUnterminated(donation: Donation | undefined): boolean {
|
|
1002
|
+
return donation !== undefined && donation.status !== 'terminated';
|
|
1003
|
+
}
|
|
1004
|
+
|
|
953
1005
|
/**
|
|
954
1006
|
* Whether a record is still a stale-`awaiting_seed` reap candidate: the requester
|
|
955
1007
|
* provisioned a node and has not presented a seed since `cutoff`. Stated once and
|