@serfab/cadre-host 0.13.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 -80
- 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 +30 -11
- package/dist/server/routes/bootstrap-node-validation.js +32 -13
- package/dist/server/routes/bootstrap-node-validation.js.map +1 -1
- 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 +4 -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 +190 -79
- package/src/bin/host.ts +9 -1
- package/src/donation/__tests__/donation-service.test.ts +1155 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +548 -510
- 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 -1143
- 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 +12 -8
- package/src/server/__tests__/grants-route.test.ts +72 -4
- 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 +32 -13
- package/src/server/server.ts +157 -122
|
@@ -14,9 +14,8 @@ import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
15
|
|
|
16
16
|
import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
17
|
-
import { allocateNodePorts, PortAllocator } from '../orchestrator/port-allocator.js';
|
|
18
17
|
import { rotateOnDisk } from '../orchestrator/log-rotator.js';
|
|
19
|
-
import { decodeDockerId, encodeDockerId } from '../orchestrator/types.js';
|
|
18
|
+
import { decodeDockerId, encodeDockerId, type NodePorts } from '../orchestrator/types.js';
|
|
20
19
|
import { StateStore } from '../orchestrator/state-store.js';
|
|
21
20
|
import { isPidAlive } from '../orchestrator/pid-liveness.js';
|
|
22
21
|
import { loadIdentity } from '../installer/identity.js';
|
|
@@ -165,7 +164,7 @@ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
|
|
|
165
164
|
// The donated-node respawn path calls createContainer again with the id of a
|
|
166
165
|
// container the host still holds a (now dead) handle for. Handles are keyed by
|
|
167
166
|
// the per-spawn dockerId, so without an explicit drop the old one would linger
|
|
168
|
-
// forever and its
|
|
167
|
+
// forever and its ports would never come back.
|
|
169
168
|
it('drops the stale handle, releases its ports, and keeps the identity', async () => {
|
|
170
169
|
const orch = makeOrchestrator();
|
|
171
170
|
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
@@ -197,6 +196,58 @@ describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
|
|
|
197
196
|
// same node rather than a new one the borrower's cadre has never approved.
|
|
198
197
|
expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
|
|
199
198
|
});
|
|
199
|
+
|
|
200
|
+
// A requester that dials the node itself (a phone) knows it only by the address it
|
|
201
|
+
// was given, so a re-spawn must come back on the SAME ports even when the allocator
|
|
202
|
+
// has lower ones free. The test above cannot tell reuse from lowest-free: there the
|
|
203
|
+
// old ports are also the lowest free ones.
|
|
204
|
+
it('comes back on its own ports even when lower ones are free', async () => {
|
|
205
|
+
const orch = makeOrchestrator();
|
|
206
|
+
|
|
207
|
+
const lower = await orch.createContainer(makeRequest('c0'));
|
|
208
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
209
|
+
await waitFor(() => orch.isRunning(first.dockerId));
|
|
210
|
+
const portsBefore = orch.getNode('c1')!.ports;
|
|
211
|
+
|
|
212
|
+
await orch.removeContainer(lower.dockerId);
|
|
213
|
+
const { pid } = decodeDockerId(first.dockerId);
|
|
214
|
+
process.kill(pid, 'SIGKILL');
|
|
215
|
+
await waitFor(() => !isPidAlive(pid));
|
|
216
|
+
|
|
217
|
+
await orch.createContainer(makeRequest('c1'));
|
|
218
|
+
expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// A handle written to state.json before the WebSocket port existed has no `ws`.
|
|
222
|
+
// Rehydrating it must hold the ports it does have, and its re-spawn must keep those
|
|
223
|
+
// and gain a WebSocket port rather than trip over the missing key.
|
|
224
|
+
it('re-spawns a handle persisted without a ws port on its old ports plus a fresh ws', async () => {
|
|
225
|
+
const rootDir = join(tmpRoot, 'legacy');
|
|
226
|
+
const legacyPorts = { health: 18010, metrics: 18011, p2p: 18012, admin: 18013 };
|
|
227
|
+
new StateStore(rootDir).save({
|
|
228
|
+
version: 1,
|
|
229
|
+
handles: [{
|
|
230
|
+
containerId: 'c1',
|
|
231
|
+
dockerId: encodeDockerId(999999, 'deadbeef'),
|
|
232
|
+
pid: 999999,
|
|
233
|
+
startupToken: 'deadbeef',
|
|
234
|
+
workdir: join(rootDir, 'c1'),
|
|
235
|
+
ports: legacyPorts as NodePorts,
|
|
236
|
+
spawnedAt: new Date(0).toISOString(),
|
|
237
|
+
partyId: 'party-c1',
|
|
238
|
+
profile: 'transaction',
|
|
239
|
+
}],
|
|
240
|
+
});
|
|
241
|
+
const orch = makeOrchestrator({ rootDir });
|
|
242
|
+
await orch.init();
|
|
243
|
+
|
|
244
|
+
await orch.createContainer(makeRequest('c1'));
|
|
245
|
+
|
|
246
|
+
const ports = orch.getNode('c1')!.ports;
|
|
247
|
+
expect(ports).toMatchObject(legacyPorts);
|
|
248
|
+
// The lowest free port: nothing below 18010 was held, `undefined` included.
|
|
249
|
+
expect(ports.ws).toBe(18000);
|
|
250
|
+
});
|
|
200
251
|
});
|
|
201
252
|
|
|
202
253
|
/**
|
|
@@ -261,7 +312,7 @@ describe('HostProcessOrchestrator failed launch', () => {
|
|
|
261
312
|
rmSync(storage, { force: true });
|
|
262
313
|
const second = await orch.createContainer(makeRequest('c1'));
|
|
263
314
|
|
|
264
|
-
// The restored handle held its
|
|
315
|
+
// The restored handle held its ports across the failure, and the
|
|
265
316
|
// allocator hands back the lowest free port — so identical ports prove the
|
|
266
317
|
// failed attempt neither leaked nor stole them.
|
|
267
318
|
expect(second.p2pPort).toBe(first.p2pPort);
|
|
@@ -279,16 +330,16 @@ describe('HostProcessOrchestrator failed launch', () => {
|
|
|
279
330
|
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
280
331
|
|
|
281
332
|
await orch.createContainer(makeRequest('c1'));
|
|
282
|
-
expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003 });
|
|
333
|
+
expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003, ws: 12004 });
|
|
283
334
|
|
|
284
|
-
// No prior handle to restore here — the
|
|
335
|
+
// No prior handle to restore here — the five ports are simply released.
|
|
285
336
|
mkdirSync(join(rootDir, 'c2'), { recursive: true });
|
|
286
337
|
writeFileSync(join(rootDir, 'c2', 'storage'), 'not-a-directory', 'utf8');
|
|
287
338
|
await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow();
|
|
288
339
|
|
|
289
340
|
await orch.createContainer(makeRequest('c3'));
|
|
290
|
-
// With the leak these would start at
|
|
291
|
-
expect(orch.getNode('c3')!.ports).toEqual({ health:
|
|
341
|
+
// With the leak these would start at 12010.
|
|
342
|
+
expect(orch.getNode('c3')!.ports).toEqual({ health: 12005, metrics: 12006, p2p: 12007, admin: 12008, ws: 12009 });
|
|
292
343
|
});
|
|
293
344
|
|
|
294
345
|
it('leaves the owner node addressable on its original ports', async () => {
|
|
@@ -348,7 +399,7 @@ describe('HostProcessOrchestrator failed launch', () => {
|
|
|
348
399
|
// persist must not reach the unwind that would delete it.
|
|
349
400
|
expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
|
|
350
401
|
|
|
351
|
-
// Its ports are still held: a fresh container gets
|
|
402
|
+
// Its ports are still held: a fresh container gets different ones.
|
|
352
403
|
const other = await orch.createContainer(makeRequest('c2'));
|
|
353
404
|
expect(other.p2pPort).not.toBe(second.p2pPort);
|
|
354
405
|
expect(orch.getNode('c2')!.ports).not.toEqual(orch.getNode('c1')!.ports);
|
|
@@ -361,8 +412,8 @@ describe('HostProcessOrchestrator failed launch', () => {
|
|
|
361
412
|
// unwind then correctly declines to delete it. Exhausting the port range
|
|
362
413
|
// fails the very next step instead, with the workdir genuinely fresh.
|
|
363
414
|
it('discards the workdir a failed first spawn created', async () => {
|
|
364
|
-
// Exactly
|
|
365
|
-
const orch = makeOrchestrator({ portRange: { start: 13000, end:
|
|
415
|
+
// Exactly five ports — enough for one node and no more.
|
|
416
|
+
const orch = makeOrchestrator({ portRange: { start: 13000, end: 13004 } });
|
|
366
417
|
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
367
418
|
|
|
368
419
|
await orch.createContainer(makeRequest('c1'));
|
|
@@ -494,7 +545,7 @@ describe('HostProcessOrchestrator.reclaimWorkdir', () => {
|
|
|
494
545
|
});
|
|
495
546
|
|
|
496
547
|
describe('HostProcessOrchestrator.isRunning', () => {
|
|
497
|
-
it('is true after spawn
|
|
548
|
+
it('is true after spawn and false after kill for a child this orchestrator spawned', async () => {
|
|
498
549
|
const orch = makeOrchestrator();
|
|
499
550
|
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
500
551
|
const { pid } = decodeDockerId(dockerId);
|
|
@@ -502,20 +553,137 @@ describe('HostProcessOrchestrator.isRunning', () => {
|
|
|
502
553
|
await waitFor(() => orch.isRunning(dockerId));
|
|
503
554
|
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
504
555
|
|
|
505
|
-
//
|
|
556
|
+
// The token file is not consulted for a child this process spawned — its
|
|
557
|
+
// ChildProcess is exact, and the file has a start-up window it does not.
|
|
506
558
|
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
507
559
|
writeFileSync(join(workdir, '.startup-token'), 'tampered', 'utf8');
|
|
508
|
-
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
509
|
-
|
|
510
|
-
// Restore token, kill the process
|
|
511
|
-
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
512
|
-
writeFileSync(join(workdir, '.startup-token'), persisted.handles[0]!.startupToken, 'utf8');
|
|
513
560
|
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
514
561
|
|
|
515
562
|
process.kill(pid, 'SIGKILL');
|
|
516
563
|
await waitFor(() => !isPidAlive(pid));
|
|
517
564
|
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
518
565
|
});
|
|
566
|
+
|
|
567
|
+
it('checks a re-attached handle by pid plus startup token', async () => {
|
|
568
|
+
const rootDir = join(tmpRoot, 'reattach');
|
|
569
|
+
const a = makeOrchestrator({ rootDir });
|
|
570
|
+
const { dockerId } = await a.createContainer(makeRequest('c1'));
|
|
571
|
+
const { pid } = decodeDockerId(dockerId);
|
|
572
|
+
await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
|
|
573
|
+
|
|
574
|
+
// A new orchestrator has no ChildProcess for it — the host-restart case.
|
|
575
|
+
const b = makeOrchestrator({ rootDir });
|
|
576
|
+
await b.init();
|
|
577
|
+
expect(await b.isRunning(dockerId)).toBe(true);
|
|
578
|
+
|
|
579
|
+
// A live pid whose token does not match may be an unrelated process that
|
|
580
|
+
// inherited the pid, so it does not count as ours.
|
|
581
|
+
const tokenPath = join(rootDir, 'c1', '.startup-token');
|
|
582
|
+
const token = readFileSync(tokenPath, 'utf8');
|
|
583
|
+
writeFileSync(tokenPath, 'tampered', 'utf8');
|
|
584
|
+
expect(await b.isRunning(dockerId)).toBe(false);
|
|
585
|
+
|
|
586
|
+
writeFileSync(tokenPath, token, 'utf8');
|
|
587
|
+
expect(await b.isRunning(dockerId)).toBe(true);
|
|
588
|
+
|
|
589
|
+
process.kill(pid, 'SIGKILL');
|
|
590
|
+
await waitFor(() => !isPidAlive(pid));
|
|
591
|
+
expect(await b.isRunning(dockerId)).toBe(false);
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A child that binds its health port at once but writes its startup token only
|
|
597
|
+
* after `TOKEN_DELAY_MS` — the shape of a real `cadre-cli start` built before the
|
|
598
|
+
* token write moved to the front, and of any child that is slow to reach it. Dies
|
|
599
|
+
* on a port clash the way `cadre-cli start` does.
|
|
600
|
+
*
|
|
601
|
+
* The incident this guards against: the donation supervisor asked `isRunning`
|
|
602
|
+
* during that window, got `false`, and re-spawned the container. The re-spawn
|
|
603
|
+
* released and reused the same ports, rotated the seed token, and returned
|
|
604
|
+
* success; the second child then died on the port the first still held, leaving
|
|
605
|
+
* the host's records naming a dead child and the live one refusing every seed.
|
|
606
|
+
*/
|
|
607
|
+
describe('HostProcessOrchestrator re-spawn over a child that is still starting', () => {
|
|
608
|
+
const TOKEN_DELAY_MS = 3000;
|
|
609
|
+
const SLOW_TOKEN_CHILD = `
|
|
610
|
+
import fs from 'node:fs';
|
|
611
|
+
import net from 'node:net';
|
|
612
|
+
const args = process.argv.slice(2);
|
|
613
|
+
const tokenPath = args[args.indexOf('--startup-token-file') + 1];
|
|
614
|
+
const server = net.createServer();
|
|
615
|
+
server.on('error', (err) => { console.error('Failed to start cadre node: ' + err.message); process.exit(1); });
|
|
616
|
+
server.listen(Number(process.env.CADRE_HEALTH_PORT), '0.0.0.0', () => {
|
|
617
|
+
console.log('listening');
|
|
618
|
+
setTimeout(() => fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN), ${TOKEN_DELAY_MS});
|
|
619
|
+
});
|
|
620
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
621
|
+
setInterval(() => {}, 1 << 30);
|
|
622
|
+
`;
|
|
623
|
+
const TEST_TIMEOUT_MS = 20_000;
|
|
624
|
+
|
|
625
|
+
let slowChildPath: string;
|
|
626
|
+
beforeEach(() => {
|
|
627
|
+
slowChildPath = join(tmpRoot, 'slow-token-child.mjs');
|
|
628
|
+
writeFileSync(slowChildPath, SLOW_TOKEN_CHILD, 'utf8');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('reports the child running before its token is written, and refuses to launch over it', async () => {
|
|
632
|
+
const orch = makeOrchestrator({ spawn: { entrypoint: slowChildPath } });
|
|
633
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
634
|
+
const tokenPath = join(rootDir, 'c1', '.startup-token');
|
|
635
|
+
|
|
636
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
637
|
+
await waitFor(async () => (await orch.getLogs(first.dockerId)).includes('listening'));
|
|
638
|
+
const portsBefore = orch.getNode('c1')!.ports;
|
|
639
|
+
|
|
640
|
+
// Inside the start-up window: port bound, token not yet written.
|
|
641
|
+
expect(existsSync(tokenPath)).toBe(false);
|
|
642
|
+
expect(await orch.isRunning(first.dockerId)).toBe(true);
|
|
643
|
+
|
|
644
|
+
// Exactly what `DonationService.respawn` would do next.
|
|
645
|
+
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/c1 is still running/);
|
|
646
|
+
|
|
647
|
+
// Nothing was dropped, released or rotated.
|
|
648
|
+
expect(orch.listNodes()).toHaveLength(1);
|
|
649
|
+
expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
|
|
650
|
+
expect(orch.getNode('c1')!.ports).toEqual(portsBefore);
|
|
651
|
+
const other = await orch.createContainer(makeRequest('c2'));
|
|
652
|
+
expect(other.p2pPort).not.toBe(first.p2pPort);
|
|
653
|
+
expect(other.healthEndpoint).not.toBe(first.healthEndpoint);
|
|
654
|
+
|
|
655
|
+
// The first child finishes starting undisturbed, with the token it was given.
|
|
656
|
+
const { pid, token } = decodeDockerId(first.dockerId);
|
|
657
|
+
await waitFor(() => existsSync(tokenPath), TOKEN_DELAY_MS + 5000);
|
|
658
|
+
expect(readFileSync(tokenPath, 'utf8')).toBe(token);
|
|
659
|
+
expect(isPidAlive(pid)).toBe(true);
|
|
660
|
+
expect(await orch.getLogs(first.dockerId)).not.toContain('EADDRINUSE');
|
|
661
|
+
}, TEST_TIMEOUT_MS);
|
|
662
|
+
|
|
663
|
+
it('refuses to launch over a re-attached child whose ports are still bound', async () => {
|
|
664
|
+
const rootDir = join(tmpRoot, 'reattach-starting');
|
|
665
|
+
const a = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
|
|
666
|
+
const first = await a.createContainer(makeRequest('c1'));
|
|
667
|
+
await waitFor(async () => (await a.getLogs(first.dockerId)).includes('listening'));
|
|
668
|
+
|
|
669
|
+
// A host restart while the child is mid-start: no ChildProcess, no token yet.
|
|
670
|
+
const b = makeOrchestrator({ rootDir, spawn: { entrypoint: slowChildPath } });
|
|
671
|
+
await b.init();
|
|
672
|
+
expect(await b.isRunning(first.dockerId)).toBe(false);
|
|
673
|
+
|
|
674
|
+
await expect(b.createContainer(makeRequest('c1')))
|
|
675
|
+
.rejects.toThrow(/c1 may still be running: port \d+ on 0\.0\.0\.0 is unavailable/);
|
|
676
|
+
|
|
677
|
+
// Nothing dropped: the record's dockerId still resolves on its ports, and
|
|
678
|
+
// the live child was not killed on unverified evidence.
|
|
679
|
+
expect(b.listNodes()).toHaveLength(1);
|
|
680
|
+
expect(b.getNode('c1')?.dockerId).toBe(first.dockerId);
|
|
681
|
+
const { pid } = decodeDockerId(first.dockerId);
|
|
682
|
+
expect(isPidAlive(pid)).toBe(true);
|
|
683
|
+
|
|
684
|
+
// Once it has written its token it is recognised as running.
|
|
685
|
+
await waitFor(() => b.isRunning(first.dockerId), TOKEN_DELAY_MS + 5000);
|
|
686
|
+
}, TEST_TIMEOUT_MS);
|
|
519
687
|
});
|
|
520
688
|
|
|
521
689
|
describe('HostProcessOrchestrator.stopContainer', () => {
|
|
@@ -704,73 +872,16 @@ describe('rotateOnDisk', () => {
|
|
|
704
872
|
});
|
|
705
873
|
});
|
|
706
874
|
|
|
707
|
-
describe('PortAllocator', () => {
|
|
708
|
-
it('allocates sequentially, releases, and reuses', () => {
|
|
709
|
-
const a = new PortAllocator(100, 102);
|
|
710
|
-
expect(a.allocate()).toBe(100);
|
|
711
|
-
expect(a.allocate()).toBe(101);
|
|
712
|
-
expect(a.allocate()).toBe(102);
|
|
713
|
-
expect(() => a.allocate()).toThrow(/No available ports/);
|
|
714
|
-
a.release(101);
|
|
715
|
-
expect(a.allocate()).toBe(101);
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
it('markUsed reserves ports without allocating', () => {
|
|
719
|
-
const a = new PortAllocator(100, 102);
|
|
720
|
-
a.markUsed(101);
|
|
721
|
-
expect(a.allocate()).toBe(100);
|
|
722
|
-
expect(a.allocate()).toBe(102);
|
|
723
|
-
expect(() => a.allocate()).toThrow();
|
|
724
|
-
});
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
describe('allocateNodePorts', () => {
|
|
728
|
-
it('allocates the four ports in a fixed order', () => {
|
|
729
|
-
const a = new PortAllocator(10000, 10010);
|
|
730
|
-
expect(allocateNodePorts(a)).toEqual({ health: 10000, metrics: 10001, p2p: 10002, admin: 10003 });
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
// A partial set left reserved would leak from a bounded range on every failed
|
|
734
|
-
// provision — the whole reason this helper exists rather than four allocates.
|
|
735
|
-
it('is all-or-nothing: an exhausted range releases everything it took', () => {
|
|
736
|
-
const a = new PortAllocator(13000, 13002);
|
|
737
|
-
expect(() => allocateNodePorts(a)).toThrow(/No available ports/);
|
|
738
|
-
expect(a.has(13000)).toBe(false);
|
|
739
|
-
expect(a.has(13001)).toBe(false);
|
|
740
|
-
expect(a.has(13002)).toBe(false);
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
// The owner node's p2p port is pinned by the NAT mapping, not allocated.
|
|
744
|
-
it('honours an override and reserves it', () => {
|
|
745
|
-
const a = new PortAllocator(10000, 10010);
|
|
746
|
-
expect(allocateNodePorts(a, { p2p: 10005 })).toEqual({
|
|
747
|
-
health: 10000,
|
|
748
|
-
metrics: 10001,
|
|
749
|
-
p2p: 10005,
|
|
750
|
-
admin: 10002,
|
|
751
|
-
});
|
|
752
|
-
// Reserved, so no later allocation can hand it out again.
|
|
753
|
-
expect(a.has(10005)).toBe(true);
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
// An out-of-range override is the production case for the owner node: its
|
|
757
|
-
// libp2p port is chosen by NAT config, not by the allocator, so reserving it
|
|
758
|
-
// is a documented no-op rather than an error.
|
|
759
|
-
it('accepts an override outside the managed range without reserving it', () => {
|
|
760
|
-
const a = new PortAllocator(10000, 10010);
|
|
761
|
-
expect(allocateNodePorts(a, { p2p: 40000 })).toMatchObject({ p2p: 40000, health: 10000 });
|
|
762
|
-
expect(a.has(40000)).toBe(false);
|
|
763
|
-
});
|
|
764
|
-
});
|
|
765
|
-
|
|
766
875
|
describe('Restart recovery (init)', () => {
|
|
767
876
|
it('re-attaches to surviving children and reports dead ones', async () => {
|
|
768
877
|
const rootDir = join(tmpRoot, 'restart');
|
|
769
878
|
const a = makeOrchestrator({ rootDir });
|
|
770
879
|
const r1 = await a.createContainer(makeRequest('c1'));
|
|
771
880
|
const r2 = await a.createContainer(makeRequest('c2'));
|
|
772
|
-
|
|
773
|
-
|
|
881
|
+
// Not `a.isRunning`: that answers from the ChildProcess at once, but `b`
|
|
882
|
+
// below can only recognise c1 once c1 has written its startup token.
|
|
883
|
+
await waitFor(() => existsSync(join(rootDir, 'c1', '.startup-token')));
|
|
884
|
+
await waitFor(() => existsSync(join(rootDir, 'c2', '.startup-token')));
|
|
774
885
|
|
|
775
886
|
// Kill c2 externally
|
|
776
887
|
const { pid: pid2 } = decodeDockerId(r2.dockerId);
|
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
|
}
|