@serfab/cadre-host 0.12.0 → 1.0.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 +6 -4
- package/dist/bin/host.js +9 -1
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.js +1 -1
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -1
- package/dist/donation/donation-service.d.ts +7 -2
- package/dist/donation/donation-service.js +16 -2
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-supervisor.d.ts +12 -2
- package/dist/donation/donation-supervisor.js +25 -2
- package/dist/donation/donation-supervisor.js.map +1 -1
- package/dist/donation/types.d.ts +2 -1
- package/dist/donation/types.js.map +1 -1
- package/dist/installer/index.d.ts +9 -1
- package/dist/installer/index.js +5 -1
- package/dist/installer/index.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +71 -11
- package/dist/orchestrator/host-process-orchestrator.js +173 -75
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +44 -2
- package/dist/orchestrator/port-allocator.js +63 -5
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/port-probe.d.ts +15 -0
- package/dist/orchestrator/port-probe.js +22 -0
- package/dist/orchestrator/port-probe.js.map +1 -0
- package/dist/orchestrator/types.d.ts +13 -1
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +115 -0
- package/dist/server/routes/bootstrap-node-validation.js +197 -0
- package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
- package/dist/server/routes/grants.js +8 -25
- package/dist/server/routes/grants.js.map +1 -1
- package/dist/server/routes/provision-request-validation.d.ts +45 -0
- package/dist/server/routes/provision-request-validation.js +97 -0
- package/dist/server/routes/provision-request-validation.js.map +1 -0
- package/dist/server/server.d.ts +4 -1
- package/dist/server/server.js +36 -2
- package/dist/server/server.js.map +1 -1
- package/dist/ui/assets/{index-C-iks41M.js → index-CEoNRwM_.js} +3 -3
- package/dist/ui/assets/{index-C-iks41M.js.map → index-CEoNRwM_.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/package.json +5 -4
- package/src/__tests__/orchestrator-env-scrub.test.ts +22 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +4 -4
- package/src/__tests__/orchestrator-owner.test.ts +45 -2
- package/src/__tests__/orchestrator-ports.test.ts +172 -0
- package/src/__tests__/orchestrator.test.ts +1062 -951
- package/src/bin/host.ts +9 -1
- package/src/donation/__tests__/donation-service.test.ts +94 -7
- package/src/donation/__tests__/donation-supervisor.test.ts +50 -12
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +1 -1
- package/src/donation/donation-service.ts +22 -3
- package/src/donation/donation-supervisor.ts +25 -2
- package/src/donation/types.ts +2 -1
- package/src/installer/__tests__/installer.smoke.test.ts +263 -218
- package/src/installer/index.ts +327 -314
- package/src/orchestrator/host-process-orchestrator.ts +1250 -1138
- package/src/orchestrator/port-allocator.ts +148 -91
- package/src/orchestrator/port-probe.ts +31 -0
- package/src/orchestrator/types.ts +13 -1
- package/src/server/__tests__/bootstrap-node-validation.test.ts +138 -0
- package/src/server/__tests__/grants-route.test.ts +394 -263
- package/src/server/__tests__/nodes-route.test.ts +2 -2
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/status-route.test.ts +1 -1
- package/src/server/routes/bootstrap-node-validation.ts +206 -0
- package/src/server/routes/grants.ts +9 -26
- package/src/server/routes/provision-request-validation.ts +105 -0
- package/src/server/server.ts +157 -122
package/src/bin/host.ts
CHANGED
|
@@ -102,6 +102,7 @@ program
|
|
|
102
102
|
.option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
|
|
103
103
|
.option('--system', 'System-wide install (not yet supported in v1)')
|
|
104
104
|
.option('--node-path <path>', 'Override the node binary embedded in the service unit')
|
|
105
|
+
.option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
|
|
105
106
|
.action(async (opts: {
|
|
106
107
|
nonInteractive?: boolean;
|
|
107
108
|
dataDir?: string;
|
|
@@ -113,6 +114,7 @@ program
|
|
|
113
114
|
ownCadre?: boolean;
|
|
114
115
|
system?: boolean;
|
|
115
116
|
nodePath?: string;
|
|
117
|
+
service?: boolean;
|
|
116
118
|
}) => {
|
|
117
119
|
const installer = new Installer();
|
|
118
120
|
try {
|
|
@@ -127,11 +129,17 @@ program
|
|
|
127
129
|
ownCadre: opts.ownCadre === true,
|
|
128
130
|
system: Boolean(opts.system),
|
|
129
131
|
...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
|
|
132
|
+
noService: opts.service === false,
|
|
130
133
|
});
|
|
131
134
|
console.log(`cadre-host installed.`);
|
|
132
135
|
console.log(` Data dir: ${result.dataDir}`);
|
|
133
136
|
console.log(` UI: ${result.uiUrl}`);
|
|
134
|
-
|
|
137
|
+
if (result.serviceName) {
|
|
138
|
+
console.log(` Service: ${result.serviceName}`);
|
|
139
|
+
} else {
|
|
140
|
+
console.log(' Service: not registered (--no-service)');
|
|
141
|
+
console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
|
|
142
|
+
}
|
|
135
143
|
if (result.enrollmentInvite) {
|
|
136
144
|
printEnrollmentInvite(result.enrollmentInvite);
|
|
137
145
|
}
|
|
@@ -95,7 +95,7 @@ function makeGrants(opts?: { maxNodes?: number }): { grants: GrantService; token
|
|
|
95
95
|
const baseRequest = (grantToken: string) => ({
|
|
96
96
|
grantToken,
|
|
97
97
|
partyId: 'party-P',
|
|
98
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
98
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
99
99
|
ownerKeys: [OWNER_KEY],
|
|
100
100
|
});
|
|
101
101
|
|
|
@@ -129,6 +129,21 @@ describe('DonationService.provision', () => {
|
|
|
129
129
|
expect(store.liveNodeCount(token)).toBe(1);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// A phone has no address to give: it sends no bootstrap nodes and dials the lent
|
|
133
|
+
// node itself. The empty list is a real spawn input, forwarded and persisted as given.
|
|
134
|
+
it('provisions with no bootstrap nodes, forwarding and persisting the empty list', async () => {
|
|
135
|
+
const orch = new FakeOrchestrator();
|
|
136
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
137
|
+
const { grants, token } = makeGrants();
|
|
138
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
139
|
+
|
|
140
|
+
const view = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
|
|
141
|
+
|
|
142
|
+
expect(view.status).toBe('awaiting_seed');
|
|
143
|
+
expect(orch.createCalls[0].bootstrapNodes).toEqual([]);
|
|
144
|
+
expect(store.get(view.id)?.bootstrapNodes).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
132
147
|
/**
|
|
133
148
|
* The pins are checked before the grant is even validated, so a typo costs the
|
|
134
149
|
* requester nothing: no orchestrator call, no record, and — the point — no quota
|
|
@@ -395,6 +410,31 @@ describe('DonationService.applySeed', () => {
|
|
|
395
410
|
expect(store.get(provisioned.id)).toEqual(before);
|
|
396
411
|
});
|
|
397
412
|
|
|
413
|
+
// A 401 is the node refusing the HOST's bearer — it never looked at the seed —
|
|
414
|
+
// so it must not read as the requester's seed being untrusted.
|
|
415
|
+
it('reports a node that refuses the host credential as a credential fault, not a seed rejection', async () => {
|
|
416
|
+
const orch = new FakeOrchestrator();
|
|
417
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
418
|
+
const { grants, token } = makeGrants();
|
|
419
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
420
|
+
|
|
421
|
+
const provisioned = await svc.provision(baseRequest(token));
|
|
422
|
+
const before = store.get(provisioned.id)!;
|
|
423
|
+
globalThis.fetch = (async () => new Response(
|
|
424
|
+
JSON.stringify({ success: false, error: 'unauthorized' }),
|
|
425
|
+
{ status: 401, headers: { 'content-type': 'application/json' } },
|
|
426
|
+
)) as typeof globalThis.fetch;
|
|
427
|
+
|
|
428
|
+
const err = await svc.applySeed(provisioned.id, 'encoded-seed').catch((e: unknown) => e);
|
|
429
|
+
|
|
430
|
+
expect(err).toBeInstanceOf(DonationError);
|
|
431
|
+
expect(err).toMatchObject({
|
|
432
|
+
code: 'seed_failed',
|
|
433
|
+
message: 'Donated node rejected the host\'s seed credential (401)',
|
|
434
|
+
});
|
|
435
|
+
expect(store.get(provisioned.id)).toEqual(before);
|
|
436
|
+
});
|
|
437
|
+
|
|
398
438
|
it('lets a borrower terminate that lands mid-seed win', async () => {
|
|
399
439
|
const orch = new FakeOrchestrator();
|
|
400
440
|
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
@@ -509,7 +549,7 @@ describe('DonationService.respawn', () => {
|
|
|
509
549
|
expect(orch.createCalls[1]).toMatchObject({
|
|
510
550
|
containerId: provisioned.id,
|
|
511
551
|
partyId: 'party-P',
|
|
512
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
552
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
513
553
|
pinnedOwnerKeys: [OWNER_KEY],
|
|
514
554
|
profile: 'storage',
|
|
515
555
|
});
|
|
@@ -564,6 +604,49 @@ describe('DonationService.respawn', () => {
|
|
|
564
604
|
expect(store.get('grn_legacy')?.dockerId).toBe('dock_old');
|
|
565
605
|
});
|
|
566
606
|
|
|
607
|
+
// The empty list a phone requester provisions with is a persisted spawn input like
|
|
608
|
+
// any other, so that loan must come back too.
|
|
609
|
+
it('respawns a record provisioned with no bootstrap nodes, replaying the empty list', async () => {
|
|
610
|
+
const orch = new FakeOrchestrator();
|
|
611
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
612
|
+
const { grants, token } = makeGrants();
|
|
613
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
614
|
+
|
|
615
|
+
const provisioned = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
|
|
616
|
+
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
617
|
+
|
|
618
|
+
const result = await svc.respawn(provisioned.id);
|
|
619
|
+
|
|
620
|
+
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
621
|
+
expect(orch.createCalls).toHaveLength(2);
|
|
622
|
+
expect(orch.createCalls[1].bootstrapNodes).toEqual([]);
|
|
623
|
+
expect(store.get(provisioned.id)?.dockerId).toBe('dock_2');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Only the field's ABSENCE marks a record from before spawn inputs were persisted,
|
|
627
|
+
// so owner keys alone do not make a record replayable.
|
|
628
|
+
it('skips a record that has owner keys but no bootstrapNodes field', async () => {
|
|
629
|
+
const orch = new FakeOrchestrator();
|
|
630
|
+
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
631
|
+
const { grants, token } = makeGrants();
|
|
632
|
+
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
633
|
+
|
|
634
|
+
store.put({
|
|
635
|
+
id: 'grn_partial',
|
|
636
|
+
grantToken: token,
|
|
637
|
+
partyId: 'party-P',
|
|
638
|
+
ownerKeys: [OWNER_KEY],
|
|
639
|
+
profile: 'storage',
|
|
640
|
+
status: 'seeded',
|
|
641
|
+
dockerId: 'dock_old',
|
|
642
|
+
createdAt: '2025-01-01T00:00:00.000Z',
|
|
643
|
+
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await expect(svc.respawn('grn_partial')).resolves.toEqual({ outcome: 'not_respawnable' });
|
|
647
|
+
expect(orch.createCalls).toHaveLength(0);
|
|
648
|
+
});
|
|
649
|
+
|
|
567
650
|
it('refuses to resurrect a terminated loan', async () => {
|
|
568
651
|
const orch = new FakeOrchestrator();
|
|
569
652
|
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
@@ -606,8 +689,12 @@ describe('DonationService.respawn', () => {
|
|
|
606
689
|
|
|
607
690
|
const stored = store.get(provisioned.id)!;
|
|
608
691
|
expect(stored.respawn?.attempts).toBe(1);
|
|
609
|
-
// The old handles are untouched — the caller owns backoff, not cleanup.
|
|
692
|
+
// The old handles are untouched — the caller owns backoff, not cleanup. The
|
|
693
|
+
// seed token above all: the child still running was started with it, so a
|
|
694
|
+
// rotated token would make that child refuse every later seed.
|
|
610
695
|
expect(stored.dockerId).toBe('dock_1');
|
|
696
|
+
expect(stored.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
|
|
697
|
+
expect(stored.seedToken).toBe('seed-token-1');
|
|
611
698
|
expect(stored.status).toBe('awaiting_seed');
|
|
612
699
|
});
|
|
613
700
|
|
|
@@ -833,7 +920,7 @@ describe('DonationService.terminate', () => {
|
|
|
833
920
|
id: 'grn_stuck',
|
|
834
921
|
grantToken: token,
|
|
835
922
|
partyId: 'party-P',
|
|
836
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
923
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
837
924
|
ownerKeys: [OWNER_KEY],
|
|
838
925
|
profile: 'storage',
|
|
839
926
|
status: 'provisioning',
|
|
@@ -952,7 +1039,7 @@ describe('DonationService.reapStaleProvisioning', () => {
|
|
|
952
1039
|
id: opts.id,
|
|
953
1040
|
grantToken: opts.token,
|
|
954
1041
|
partyId: 'party-P',
|
|
955
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
1042
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
956
1043
|
ownerKeys: [OWNER_KEY],
|
|
957
1044
|
profile: 'storage',
|
|
958
1045
|
status: 'provisioning',
|
|
@@ -998,7 +1085,7 @@ describe('DonationService.reapStaleProvisioning', () => {
|
|
|
998
1085
|
const spawn = await orch.createContainer({
|
|
999
1086
|
containerId: 'grn_stuck',
|
|
1000
1087
|
partyId: 'party-P',
|
|
1001
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
1088
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
1002
1089
|
profile: 'storage',
|
|
1003
1090
|
pinnedOwnerKeys: [OWNER_KEY],
|
|
1004
1091
|
});
|
|
@@ -1041,7 +1128,7 @@ describe('DonationService.reapStaleProvisioning', () => {
|
|
|
1041
1128
|
const firstSpawn = await orch.createContainer({
|
|
1042
1129
|
containerId: 'grn_first',
|
|
1043
1130
|
partyId: 'party-P',
|
|
1044
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
1131
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
1045
1132
|
profile: 'storage',
|
|
1046
1133
|
pinnedOwnerKeys: [OWNER_KEY],
|
|
1047
1134
|
});
|
|
@@ -84,7 +84,7 @@ function makeHarness(): Harness {
|
|
|
84
84
|
provision: () => service.provision({
|
|
85
85
|
grantToken: token,
|
|
86
86
|
partyId: 'party-P',
|
|
87
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
87
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
88
88
|
ownerKeys: [OWNER_KEY],
|
|
89
89
|
}),
|
|
90
90
|
};
|
|
@@ -229,6 +229,43 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
229
229
|
expect(h.store.liveNodeCount(stored.grantToken)).toBe(0);
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
// A spawn that succeeds and then dies at once (a crash on boot, a port clash)
|
|
233
|
+
// never throws out of `respawn`, so a give-up checked only on a throw would
|
|
234
|
+
// respawn such a node forever, rotating its seed token every time.
|
|
235
|
+
it('gives up on a node whose every respawn spawns but does not stay up', async () => {
|
|
236
|
+
const h = makeHarness();
|
|
237
|
+
const view = await h.provision();
|
|
238
|
+
setStatus(h.store, view.id, 'seeded');
|
|
239
|
+
h.orch.crash(dockerIdOf(view));
|
|
240
|
+
h.orch.onSpawned = (dockerId) => { h.orch.crash(dockerId); };
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < DONATION_RESPAWN_MAX_ATTEMPTS; i++) {
|
|
243
|
+
await expect(h.supervisor.reconcile()).resolves.toEqual([view.id]);
|
|
244
|
+
// Well past the longest backoff, so every pass gets to act.
|
|
245
|
+
h.advance(10 * 60_000);
|
|
246
|
+
}
|
|
247
|
+
expect(h.orch.createCalls).toHaveLength(1 + DONATION_RESPAWN_MAX_ATTEMPTS);
|
|
248
|
+
expect(requireDonation(h.store, view.id).status).toBe('seeded');
|
|
249
|
+
|
|
250
|
+
// The budget is spent and the last respawn died too: this pass gives up
|
|
251
|
+
// instead of spawning again.
|
|
252
|
+
await expect(h.supervisor.reconcile()).resolves.toEqual([]);
|
|
253
|
+
const stored = requireDonation(h.store, view.id);
|
|
254
|
+
expect(h.orch.createCalls).toHaveLength(1 + DONATION_RESPAWN_MAX_ATTEMPTS);
|
|
255
|
+
expect(stored.status).toBe('error');
|
|
256
|
+
expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS);
|
|
257
|
+
expect(stored.error).toContain(`${DONATION_RESPAWN_MAX_ATTEMPTS} attempts`);
|
|
258
|
+
expect(stored.error).toContain('did not stay running');
|
|
259
|
+
// The child the record names is stopped, never reclaimed.
|
|
260
|
+
expect(h.orch.stopped).toEqual([stored.dockerId]);
|
|
261
|
+
expect(h.orch.removed).toEqual([]);
|
|
262
|
+
expect(h.store.liveNodeCount(stored.grantToken)).toBe(0);
|
|
263
|
+
|
|
264
|
+
h.advance(10 * 60_000);
|
|
265
|
+
await expect(h.supervisor.reconcile()).resolves.toEqual([]);
|
|
266
|
+
expect(h.orch.createCalls).toHaveLength(1 + DONATION_RESPAWN_MAX_ATTEMPTS);
|
|
267
|
+
});
|
|
268
|
+
|
|
232
269
|
it('refills the attempt budget once a respawned node has stayed up', async () => {
|
|
233
270
|
const h = makeHarness();
|
|
234
271
|
const view = await h.provision();
|
|
@@ -284,7 +321,7 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
284
321
|
id: 'grn_inflight',
|
|
285
322
|
grantToken: 'tok',
|
|
286
323
|
partyId: 'party-P',
|
|
287
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
324
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
288
325
|
ownerKeys: [OWNER_KEY],
|
|
289
326
|
profile: 'storage',
|
|
290
327
|
status: 'provisioning',
|
|
@@ -304,7 +341,7 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
304
341
|
id: 'grn_nohandle',
|
|
305
342
|
grantToken: 'tok',
|
|
306
343
|
partyId: 'party-P',
|
|
307
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
344
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
308
345
|
ownerKeys: [OWNER_KEY],
|
|
309
346
|
profile: 'storage',
|
|
310
347
|
status: 'awaiting_seed',
|
|
@@ -349,10 +386,11 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
349
386
|
h.store.put({
|
|
350
387
|
...requireDonation(h.store, view.id),
|
|
351
388
|
status: 'seeded',
|
|
352
|
-
//
|
|
353
|
-
//
|
|
389
|
+
// One attempt short of the cap (at the cap, the pass would give up before
|
|
390
|
+
// attempting), and long past the longest backoff — so the next failure
|
|
391
|
+
// lands straight in the give-up path.
|
|
354
392
|
respawn: {
|
|
355
|
-
attempts: DONATION_RESPAWN_MAX_ATTEMPTS,
|
|
393
|
+
attempts: DONATION_RESPAWN_MAX_ATTEMPTS - 1,
|
|
356
394
|
lastAttemptAt: new Date(h.nowMs() - 10 * 60_000).toISOString(),
|
|
357
395
|
},
|
|
358
396
|
});
|
|
@@ -370,7 +408,7 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
370
408
|
expect(stored.status).toBe('terminated');
|
|
371
409
|
expect(stored.error).toBeUndefined();
|
|
372
410
|
// The attempt counter is the one thing the failed attempt may still record.
|
|
373
|
-
expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS
|
|
411
|
+
expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS);
|
|
374
412
|
expect(h.orch.stopped).toEqual([]);
|
|
375
413
|
});
|
|
376
414
|
|
|
@@ -380,10 +418,10 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
380
418
|
h.store.put({
|
|
381
419
|
...requireDonation(h.store, view.id),
|
|
382
420
|
status: 'seeded',
|
|
383
|
-
//
|
|
384
|
-
// attempt here would land straight in the give-up path.
|
|
421
|
+
// One attempt short of the cap and long past the longest backoff, so a
|
|
422
|
+
// *failed* attempt here would land straight in the give-up path.
|
|
385
423
|
respawn: {
|
|
386
|
-
attempts: DONATION_RESPAWN_MAX_ATTEMPTS,
|
|
424
|
+
attempts: DONATION_RESPAWN_MAX_ATTEMPTS - 1,
|
|
387
425
|
lastAttemptAt: new Date(h.nowMs() - 10 * 60_000).toISOString(),
|
|
388
426
|
},
|
|
389
427
|
});
|
|
@@ -400,7 +438,7 @@ describe('DonationSupervisor.reconcile', () => {
|
|
|
400
438
|
// Nothing threw, so no attempt was persisted and give-up never ran.
|
|
401
439
|
expect(stored.status).toBe('terminated');
|
|
402
440
|
expect(stored.error).toBeUndefined();
|
|
403
|
-
expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS);
|
|
441
|
+
expect(stored.respawn?.attempts).toBe(DONATION_RESPAWN_MAX_ATTEMPTS - 1);
|
|
404
442
|
expect(stored.dockerId).toBe(view.dockerId);
|
|
405
443
|
// The abandoned child is stopped and reclaimed, not left holding ports.
|
|
406
444
|
expect(h.orch.stopped).toContain('dock_2');
|
|
@@ -480,7 +518,7 @@ describe('DonationSupervisor start/stop', () => {
|
|
|
480
518
|
status: 'stopped',
|
|
481
519
|
spawnedAt: new Date(START_MS).toISOString(),
|
|
482
520
|
workdir: '/fake/owner',
|
|
483
|
-
ports: { health: 0, metrics: 0, p2p: 0, admin: 0 },
|
|
521
|
+
ports: { health: 0, metrics: 0, p2p: 0, admin: 0, ws: 0 },
|
|
484
522
|
owner: true,
|
|
485
523
|
};
|
|
486
524
|
h.orch.emit(ownerExit);
|