@serfab/cadre-host 0.7.1
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 +101 -0
- package/dist/auth/duration.d.ts +1 -0
- package/dist/auth/duration.js +32 -0
- package/dist/auth/duration.js.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/trust-circle-store.d.ts +26 -0
- package/dist/auth/trust-circle-store.js +129 -0
- package/dist/auth/trust-circle-store.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +91 -0
- package/dist/auth/trust-circle.js +230 -0
- package/dist/auth/trust-circle.js.map +1 -0
- package/dist/auth/types.d.ts +81 -0
- package/dist/auth/types.js +24 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/bin/host.d.ts +13 -0
- package/dist/bin/host.js +826 -0
- package/dist/bin/host.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/browser.d.ts +14 -0
- package/dist/installer/browser.js +35 -0
- package/dist/installer/browser.js.map +1 -0
- package/dist/installer/config.d.ts +43 -0
- package/dist/installer/config.js +113 -0
- package/dist/installer/config.js.map +1 -0
- package/dist/installer/identity.d.ts +23 -0
- package/dist/installer/identity.js +36 -0
- package/dist/installer/identity.js.map +1 -0
- package/dist/installer/index.d.ts +83 -0
- package/dist/installer/index.js +204 -0
- package/dist/installer/index.js.map +1 -0
- package/dist/installer/paths.d.ts +33 -0
- package/dist/installer/paths.js +84 -0
- package/dist/installer/paths.js.map +1 -0
- package/dist/installer/platform.d.ts +9 -0
- package/dist/installer/platform.js +21 -0
- package/dist/installer/platform.js.map +1 -0
- package/dist/installer/service-host/index.d.ts +11 -0
- package/dist/installer/service-host/index.js +16 -0
- package/dist/installer/service-host/index.js.map +1 -0
- package/dist/installer/service-host/launchd.d.ts +16 -0
- package/dist/installer/service-host/launchd.js +79 -0
- package/dist/installer/service-host/launchd.js.map +1 -0
- package/dist/installer/service-host/nssm.d.ts +20 -0
- package/dist/installer/service-host/nssm.js +88 -0
- package/dist/installer/service-host/nssm.js.map +1 -0
- package/dist/installer/service-host/systemd.d.ts +16 -0
- package/dist/installer/service-host/systemd.js +78 -0
- package/dist/installer/service-host/systemd.js.map +1 -0
- package/dist/installer/service-host/template.d.ts +6 -0
- package/dist/installer/service-host/template.js +20 -0
- package/dist/installer/service-host/template.js.map +1 -0
- package/dist/installer/service-host/types.d.ts +40 -0
- package/dist/installer/service-host/types.js +6 -0
- package/dist/installer/service-host/types.js.map +1 -0
- package/dist/installer/wizard.d.ts +31 -0
- package/dist/installer/wizard.js +67 -0
- package/dist/installer/wizard.js.map +1 -0
- package/dist/nat/address-resolver.d.ts +22 -0
- package/dist/nat/address-resolver.js +31 -0
- package/dist/nat/address-resolver.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +12 -0
- package/dist/nat/ddns/duckdns.js +57 -0
- package/dist/nat/ddns/duckdns.js.map +1 -0
- package/dist/nat/ddns/index.d.ts +30 -0
- package/dist/nat/ddns/index.js +21 -0
- package/dist/nat/ddns/index.js.map +1 -0
- package/dist/nat/ddns/updater.d.ts +68 -0
- package/dist/nat/ddns/updater.js +194 -0
- package/dist/nat/ddns/updater.js.map +1 -0
- package/dist/nat/external-ip.d.ts +62 -0
- package/dist/nat/external-ip.js +114 -0
- package/dist/nat/external-ip.js.map +1 -0
- package/dist/nat/index.d.ts +22 -0
- package/dist/nat/index.js +16 -0
- package/dist/nat/index.js.map +1 -0
- package/dist/nat/nat-service.d.ts +101 -0
- package/dist/nat/nat-service.js +284 -0
- package/dist/nat/nat-service.js.map +1 -0
- package/dist/nat/nat-store.d.ts +19 -0
- package/dist/nat/nat-store.js +136 -0
- package/dist/nat/nat-store.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +98 -0
- package/dist/nat/port-mapper.js +226 -0
- package/dist/nat/port-mapper.js.map +1 -0
- package/dist/nat/reachability.d.ts +19 -0
- package/dist/nat/reachability.js +29 -0
- package/dist/nat/reachability.js.map +1 -0
- package/dist/nat/secrets/file-store.d.ts +23 -0
- package/dist/nat/secrets/file-store.js +102 -0
- package/dist/nat/secrets/file-store.js.map +1 -0
- package/dist/nat/secrets/index.d.ts +36 -0
- package/dist/nat/secrets/index.js +56 -0
- package/dist/nat/secrets/index.js.map +1 -0
- package/dist/nat/secrets/keytar-store.d.ts +18 -0
- package/dist/nat/secrets/keytar-store.js +52 -0
- package/dist/nat/secrets/keytar-store.js.map +1 -0
- package/dist/nat/types.d.ts +120 -0
- package/dist/nat/types.js +24 -0
- package/dist/nat/types.js.map +1 -0
- package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
- package/dist/orchestrator/host-process-orchestrator.js +557 -0
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.js +2 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/orchestrator/log-rotator.d.ts +25 -0
- package/dist/orchestrator/log-rotator.js +56 -0
- package/dist/orchestrator/log-rotator.js.map +1 -0
- package/dist/orchestrator/pid-liveness.d.ts +11 -0
- package/dist/orchestrator/pid-liveness.js +26 -0
- package/dist/orchestrator/pid-liveness.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +16 -0
- package/dist/orchestrator/port-allocator.js +38 -0
- package/dist/orchestrator/port-allocator.js.map +1 -0
- package/dist/orchestrator/state-store.d.ts +32 -0
- package/dist/orchestrator/state-store.js +44 -0
- package/dist/orchestrator/state-store.js.map +1 -0
- package/dist/orchestrator/types.d.ts +85 -0
- package/dist/orchestrator/types.js +31 -0
- package/dist/orchestrator/types.js.map +1 -0
- package/dist/server/error-handler.d.ts +26 -0
- package/dist/server/error-handler.js +95 -0
- package/dist/server/error-handler.js.map +1 -0
- package/dist/server/events/bus.d.ts +17 -0
- package/dist/server/events/bus.js +36 -0
- package/dist/server/events/bus.js.map +1 -0
- package/dist/server/events/sse-route.d.ts +22 -0
- package/dist/server/events/sse-route.js +65 -0
- package/dist/server/events/sse-route.js.map +1 -0
- package/dist/server/events/types.d.ts +25 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/events/types.js.map +1 -0
- package/dist/server/index.d.ts +67 -0
- package/dist/server/index.js +149 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/origin-guard.d.ts +24 -0
- package/dist/server/origin-guard.js +72 -0
- package/dist/server/origin-guard.js.map +1 -0
- package/dist/server/routes/nat.d.ts +16 -0
- package/dist/server/routes/nat.js +42 -0
- package/dist/server/routes/nat.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +23 -0
- package/dist/server/routes/nodes.js +128 -0
- package/dist/server/routes/nodes.js.map +1 -0
- package/dist/server/routes/settings.d.ts +26 -0
- package/dist/server/routes/settings.js +97 -0
- package/dist/server/routes/settings.js.map +1 -0
- package/dist/server/routes/status.d.ts +42 -0
- package/dist/server/routes/status.js +76 -0
- package/dist/server/routes/status.js.map +1 -0
- package/dist/server/routes/trust-circle.d.ts +17 -0
- package/dist/server/routes/trust-circle.js +35 -0
- package/dist/server/routes/trust-circle.js.map +1 -0
- package/dist/server/routes/update.d.ts +16 -0
- package/dist/server/routes/update.js +29 -0
- package/dist/server/routes/update.js.map +1 -0
- package/dist/server/server.d.ts +40 -0
- package/dist/server/server.js +90 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/settings-store.d.ts +24 -0
- package/dist/server/settings-store.js +27 -0
- package/dist/server/settings-store.js.map +1 -0
- package/dist/server/static.d.ts +25 -0
- package/dist/server/static.js +177 -0
- package/dist/server/static.js.map +1 -0
- package/dist/ui/assets/index-BwU5mJGF.css +1 -0
- package/dist/ui/assets/index-C9uChk2P.js +14 -0
- package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/update/apply.d.ts +37 -0
- package/dist/update/apply.js +102 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/index.d.ts +101 -0
- package/dist/update/index.js +228 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/manifest.d.ts +48 -0
- package/dist/update/manifest.js +152 -0
- package/dist/update/manifest.js.map +1 -0
- package/dist/update/release-key.d.ts +24 -0
- package/dist/update/release-key.js +52 -0
- package/dist/update/release-key.js.map +1 -0
- package/dist/update/store.d.ts +22 -0
- package/dist/update/store.js +81 -0
- package/dist/update/store.js.map +1 -0
- package/dist/update/types.d.ts +90 -0
- package/dist/update/types.js +22 -0
- package/dist/update/types.js.map +1 -0
- package/dist/update/version.d.ts +18 -0
- package/dist/update/version.js +76 -0
- package/dist/update/version.js.map +1 -0
- package/package.json +80 -0
- package/service/README.md +86 -0
- package/service/cadre-host.service.tmpl +16 -0
- package/service/com.serfab.cadre-host.plist.tmpl +35 -0
- package/service/install-service.ps1 +98 -0
- package/service/uninstall-service.ps1 +19 -0
- package/src/__tests__/cli-invite.smoke.test.ts +187 -0
- package/src/__tests__/cli-nat.smoke.test.ts +207 -0
- package/src/__tests__/cli.smoke.test.ts +41 -0
- package/src/__tests__/orchestrator.test.ts +570 -0
- package/src/auth/__tests__/duration.test.ts +28 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
- package/src/auth/__tests__/trust-circle.test.ts +334 -0
- package/src/auth/duration.ts +33 -0
- package/src/auth/index.ts +19 -0
- package/src/auth/trust-circle-store.ts +155 -0
- package/src/auth/trust-circle.ts +304 -0
- package/src/auth/types.ts +96 -0
- package/src/bin/host.ts +905 -0
- package/src/index.ts +167 -0
- package/src/installer/__tests__/config.test.ts +86 -0
- package/src/installer/__tests__/identity.test.ts +34 -0
- package/src/installer/__tests__/installer.smoke.test.ts +124 -0
- package/src/installer/__tests__/paths.test.ts +47 -0
- package/src/installer/__tests__/service-host.test.ts +62 -0
- package/src/installer/__tests__/wizard.test.ts +60 -0
- package/src/installer/browser.ts +42 -0
- package/src/installer/config.ts +153 -0
- package/src/installer/identity.ts +49 -0
- package/src/installer/index.ts +303 -0
- package/src/installer/paths.ts +109 -0
- package/src/installer/platform.ts +27 -0
- package/src/installer/service-host/index.ts +21 -0
- package/src/installer/service-host/launchd.ts +90 -0
- package/src/installer/service-host/nssm.ts +100 -0
- package/src/installer/service-host/systemd.ts +88 -0
- package/src/installer/service-host/template.ts +21 -0
- package/src/installer/service-host/types.ts +48 -0
- package/src/installer/wizard.ts +92 -0
- package/src/nat/__tests__/address-resolver.test.ts +82 -0
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
- package/src/nat/__tests__/ddns-updater.test.ts +253 -0
- package/src/nat/__tests__/external-ip.test.ts +124 -0
- package/src/nat/__tests__/nat-service.test.ts +305 -0
- package/src/nat/__tests__/nat-store.test.ts +93 -0
- package/src/nat/__tests__/port-mapper.test.ts +191 -0
- package/src/nat/__tests__/reachability.test.ts +24 -0
- package/src/nat/__tests__/secrets.test.ts +156 -0
- package/src/nat/address-resolver.ts +51 -0
- package/src/nat/ddns/duckdns.ts +76 -0
- package/src/nat/ddns/index.ts +55 -0
- package/src/nat/ddns/updater.ts +243 -0
- package/src/nat/external-ip.ts +154 -0
- package/src/nat/index.ts +71 -0
- package/src/nat/nat-service.ts +375 -0
- package/src/nat/nat-store.ts +154 -0
- package/src/nat/port-mapper.ts +329 -0
- package/src/nat/reachability.ts +34 -0
- package/src/nat/secrets/file-store.ts +130 -0
- package/src/nat/secrets/index.ts +82 -0
- package/src/nat/secrets/keytar-store.ts +67 -0
- package/src/nat/types.ts +157 -0
- package/src/orchestrator/host-process-orchestrator.ts +626 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/log-rotator.ts +53 -0
- package/src/orchestrator/pid-liveness.ts +24 -0
- package/src/orchestrator/port-allocator.ts +43 -0
- package/src/orchestrator/state-store.ts +72 -0
- package/src/orchestrator/types.ts +95 -0
- package/src/server/__tests__/bus.test.ts +38 -0
- package/src/server/__tests__/error-handler.test.ts +96 -0
- package/src/server/__tests__/nodes-route.test.ts +179 -0
- package/src/server/__tests__/origin-guard.test.ts +72 -0
- package/src/server/__tests__/publishers.test.ts +216 -0
- package/src/server/__tests__/server.smoke.test.ts +178 -0
- package/src/server/__tests__/settings-route.test.ts +177 -0
- package/src/server/__tests__/sse-route.test.ts +119 -0
- package/src/server/__tests__/status-route.test.ts +113 -0
- package/src/server/error-handler.ts +108 -0
- package/src/server/events/bus.ts +42 -0
- package/src/server/events/sse-route.ts +81 -0
- package/src/server/events/types.ts +22 -0
- package/src/server/index.ts +209 -0
- package/src/server/origin-guard.ts +84 -0
- package/src/server/routes/nat.ts +63 -0
- package/src/server/routes/nodes.ts +147 -0
- package/src/server/routes/settings.ts +115 -0
- package/src/server/routes/status.ts +108 -0
- package/src/server/routes/trust-circle.ts +49 -0
- package/src/server/routes/update.ts +43 -0
- package/src/server/server.ts +122 -0
- package/src/server/settings-store.ts +37 -0
- package/src/server/static.ts +191 -0
- package/src/update/__tests__/apply.test.ts +101 -0
- package/src/update/__tests__/manifest.test.ts +93 -0
- package/src/update/__tests__/store.test.ts +81 -0
- package/src/update/__tests__/update-service.integration.test.ts +221 -0
- package/src/update/__tests__/version.test.ts +31 -0
- package/src/update/apply.ts +148 -0
- package/src/update/index.ts +303 -0
- package/src/update/manifest.ts +172 -0
- package/src/update/release-key.ts +55 -0
- package/src/update/store.ts +101 -0
- package/src/update/types.ts +109 -0
- package/src/update/version.ts +80 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test: invokes the compiled CLI with --help and asserts the expected
|
|
3
|
+
* subcommands are listed. Relies on `yarn build` having produced dist/bin/host.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const binPath = resolve(here, '../../dist/bin/host.js');
|
|
14
|
+
|
|
15
|
+
describe('cadre-host CLI smoke test', () => {
|
|
16
|
+
it('prints help with all subcommands', () => {
|
|
17
|
+
if (!existsSync(binPath)) {
|
|
18
|
+
throw new Error(`cadre-host bin not built at ${binPath}; run \`yarn build\` first`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const stdout = execFileSync(process.execPath, [binPath, '--help'], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(stdout.length).toBeGreaterThan(0);
|
|
26
|
+
for (const cmd of ['install', 'start', 'status', 'invite', 'uninstall']) {
|
|
27
|
+
expect(stdout).toContain(cmd);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('prints version', () => {
|
|
32
|
+
if (!existsSync(binPath)) {
|
|
33
|
+
throw new Error(`cadre-host bin not built at ${binPath}; run \`yarn build\` first`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stdout = execFileSync(process.execPath, [binPath, '--version'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
});
|
|
39
|
+
expect(stdout.trim().length).toBeGreaterThan(0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostProcessOrchestrator unit tests.
|
|
3
|
+
*
|
|
4
|
+
* These tests spawn a tiny fake child script — NOT the real cadre-cli — so
|
|
5
|
+
* they stay fast and don't require cadre-cli to be built. The orchestrator
|
|
6
|
+
* exposes a `spawn.entrypoint` override for exactly this purpose.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
17
|
+
import { PortAllocator } from '../orchestrator/port-allocator.js';
|
|
18
|
+
import { rotateOnDisk } from '../orchestrator/log-rotator.js';
|
|
19
|
+
import { decodeDockerId, encodeDockerId } from '../orchestrator/types.js';
|
|
20
|
+
import { StateStore } from '../orchestrator/state-store.js';
|
|
21
|
+
import { isPidAlive } from '../orchestrator/pid-liveness.js';
|
|
22
|
+
|
|
23
|
+
const FAKE_CHILD = `
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const configIdx = args.indexOf('-c');
|
|
29
|
+
const tokenIdx = args.indexOf('--startup-token-file');
|
|
30
|
+
const tokenPath = tokenIdx >= 0 ? args[tokenIdx + 1] : null;
|
|
31
|
+
const ignoreSigterm = process.env.IGNORE_SIGTERM === '1';
|
|
32
|
+
const initialMessage = process.env.CHILD_INITIAL_MESSAGE ?? 'tick';
|
|
33
|
+
const tickIntervalMs = parseInt(process.env.CHILD_TICK_MS ?? '50', 10);
|
|
34
|
+
const tickPayload = process.env.CHILD_TICK_PAYLOAD ?? 'tick';
|
|
35
|
+
|
|
36
|
+
if (tokenPath && process.env.CADRE_STARTUP_TOKEN) {
|
|
37
|
+
try {
|
|
38
|
+
fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN, 'utf8');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('failed to write token:', err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let alive = true;
|
|
45
|
+
process.on('SIGTERM', () => {
|
|
46
|
+
if (ignoreSigterm) {
|
|
47
|
+
console.log('ignoring SIGTERM');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
alive = false;
|
|
51
|
+
console.log('exiting on SIGTERM');
|
|
52
|
+
process.exit(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
console.log(initialMessage);
|
|
56
|
+
|
|
57
|
+
const interval = setInterval(() => {
|
|
58
|
+
if (alive) {
|
|
59
|
+
process.stdout.write(tickPayload + '\\n');
|
|
60
|
+
} else {
|
|
61
|
+
clearInterval(interval);
|
|
62
|
+
}
|
|
63
|
+
}, tickIntervalMs);
|
|
64
|
+
|
|
65
|
+
setInterval(() => {}, 1 << 30);
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
let tmpRoot: string;
|
|
69
|
+
let scriptPath: string;
|
|
70
|
+
const orchestrators: HostProcessOrchestrator[] = [];
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-orch-'));
|
|
74
|
+
scriptPath = join(tmpRoot, 'fake-child.mjs');
|
|
75
|
+
writeFileSync(scriptPath, FAKE_CHILD, 'utf8');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
// Force-kill any leftover children before cleanup.
|
|
80
|
+
for (const orch of orchestrators) {
|
|
81
|
+
for (const dockerId of listDockerIds(orch)) {
|
|
82
|
+
try { await orch.removeContainer(dockerId); } catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
orchestrators.length = 0;
|
|
86
|
+
await sleep(50);
|
|
87
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
|
|
91
|
+
const rootDir = overrides.rootDir ?? join(tmpRoot, 'orch');
|
|
92
|
+
mkdirSync(rootDir, { recursive: true });
|
|
93
|
+
const orch = new HostProcessOrchestrator({
|
|
94
|
+
rootDir,
|
|
95
|
+
portRange: { start: 18000, end: 18999 },
|
|
96
|
+
stopTimeoutMs: 1500,
|
|
97
|
+
logMaxBytes: overrides.logMaxBytes ?? 10 * 1024 * 1024,
|
|
98
|
+
logMaxFiles: overrides.logMaxFiles ?? 5,
|
|
99
|
+
spawn: { entrypoint: overrides.spawn?.entrypoint ?? scriptPath },
|
|
100
|
+
...overrides,
|
|
101
|
+
});
|
|
102
|
+
orchestrators.push(orch);
|
|
103
|
+
return orch;
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
|
|
113
|
+
const deadline = Date.now() + timeoutMs;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
if (await predicate()) return;
|
|
116
|
+
await sleep(intervalMs);
|
|
117
|
+
}
|
|
118
|
+
throw new Error('waitFor: timed out');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sleep(ms: number): Promise<void> {
|
|
122
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transaction' } = {}) {
|
|
126
|
+
return {
|
|
127
|
+
containerId,
|
|
128
|
+
partyId: 'party-' + containerId,
|
|
129
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001'],
|
|
130
|
+
profile: opts.profile ?? 'transaction' as 'storage' | 'transaction',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe('HostProcessOrchestrator.createContainer', () => {
|
|
135
|
+
it('spawns a live child and persists handle, ports, and endpoints', async () => {
|
|
136
|
+
const orch = makeOrchestrator();
|
|
137
|
+
const result = await orch.createContainer(makeRequest('c1'));
|
|
138
|
+
|
|
139
|
+
// Endpoints derive from allocated ports
|
|
140
|
+
expect(result.healthEndpoint).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
141
|
+
expect(result.metricsEndpoint).toMatch(/^http:\/\/localhost:\d+\/metrics$/);
|
|
142
|
+
expect(result.p2pPort).toBeGreaterThanOrEqual(18000);
|
|
143
|
+
|
|
144
|
+
const { pid } = decodeDockerId(result.dockerId);
|
|
145
|
+
await waitFor(() => isPidAlive(pid));
|
|
146
|
+
expect(isPidAlive(pid)).toBe(true);
|
|
147
|
+
|
|
148
|
+
// Wait until the child has written its startup token file
|
|
149
|
+
await waitFor(() => orch.isRunning(result.dockerId));
|
|
150
|
+
expect(await orch.isRunning(result.dockerId)).toBe(true);
|
|
151
|
+
|
|
152
|
+
// Workdir scaffolded
|
|
153
|
+
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
154
|
+
expect(readFileSync(join(workdir, 'cadre.json'), 'utf8')).toContain('party-c1');
|
|
155
|
+
|
|
156
|
+
// State persisted
|
|
157
|
+
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
158
|
+
expect(persisted.handles).toHaveLength(1);
|
|
159
|
+
expect(persisted.handles[0]!.dockerId).toBe(result.dockerId);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('HostProcessOrchestrator.isRunning', () => {
|
|
164
|
+
it('is true after spawn, false after kill, false on tampered token', async () => {
|
|
165
|
+
const orch = makeOrchestrator();
|
|
166
|
+
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
167
|
+
const { pid } = decodeDockerId(dockerId);
|
|
168
|
+
|
|
169
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
170
|
+
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
171
|
+
|
|
172
|
+
// Tamper with the token file
|
|
173
|
+
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
174
|
+
writeFileSync(join(workdir, '.startup-token'), 'tampered', 'utf8');
|
|
175
|
+
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
176
|
+
|
|
177
|
+
// Restore token, kill the process
|
|
178
|
+
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
179
|
+
writeFileSync(join(workdir, '.startup-token'), persisted.handles[0]!.startupToken, 'utf8');
|
|
180
|
+
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
181
|
+
|
|
182
|
+
process.kill(pid, 'SIGKILL');
|
|
183
|
+
await waitFor(() => !isPidAlive(pid));
|
|
184
|
+
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('HostProcessOrchestrator.stopContainer', () => {
|
|
189
|
+
it('SIGTERM stops a cooperative child without needing SIGKILL', async () => {
|
|
190
|
+
const orch = makeOrchestrator();
|
|
191
|
+
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
192
|
+
const { pid } = decodeDockerId(dockerId);
|
|
193
|
+
|
|
194
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
195
|
+
await orch.stopContainer(dockerId);
|
|
196
|
+
|
|
197
|
+
expect(isPidAlive(pid)).toBe(false);
|
|
198
|
+
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// On Windows, process.kill ignores the signal name and always force-kills,
|
|
202
|
+
// so a child cannot ignore SIGTERM at the Node level. Skip there.
|
|
203
|
+
it.skipIf(process.platform === 'win32')(
|
|
204
|
+
'escalates to SIGKILL when the child ignores SIGTERM',
|
|
205
|
+
async () => {
|
|
206
|
+
const orch = makeOrchestrator();
|
|
207
|
+
process.env.IGNORE_SIGTERM = '1';
|
|
208
|
+
let dockerId: string;
|
|
209
|
+
try {
|
|
210
|
+
({ dockerId } = await orch.createContainer(makeRequest('c2')));
|
|
211
|
+
} finally {
|
|
212
|
+
delete process.env.IGNORE_SIGTERM;
|
|
213
|
+
}
|
|
214
|
+
const { pid } = decodeDockerId(dockerId);
|
|
215
|
+
await waitFor(() => isPidAlive(pid));
|
|
216
|
+
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
await orch.stopContainer(dockerId);
|
|
219
|
+
const elapsed = Date.now() - start;
|
|
220
|
+
|
|
221
|
+
// Should have waited the full stopTimeoutMs (1500) before SIGKILL.
|
|
222
|
+
expect(elapsed).toBeGreaterThanOrEqual(1000);
|
|
223
|
+
expect(isPidAlive(pid)).toBe(false);
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('HostProcessOrchestrator.removeContainer', () => {
|
|
229
|
+
it('releases ports, deletes workdir, drops state', async () => {
|
|
230
|
+
const orch = makeOrchestrator();
|
|
231
|
+
const { dockerId, healthEndpoint } = await orch.createContainer(makeRequest('c1'));
|
|
232
|
+
const port = Number(healthEndpoint.match(/:(\d+)/)![1]);
|
|
233
|
+
|
|
234
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
235
|
+
await orch.removeContainer(dockerId);
|
|
236
|
+
|
|
237
|
+
// Workdir is gone
|
|
238
|
+
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
239
|
+
expect(() => readFileSync(join(workdir, 'cadre.json'), 'utf8')).toThrow();
|
|
240
|
+
|
|
241
|
+
// State no longer references it
|
|
242
|
+
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
243
|
+
expect(persisted.handles).toHaveLength(0);
|
|
244
|
+
|
|
245
|
+
// Subsequent getStats rejects
|
|
246
|
+
await expect(orch.getStats(dockerId)).rejects.toThrow(/not found/i);
|
|
247
|
+
|
|
248
|
+
// Port was released — reallocate it via a fresh container; pick the next id.
|
|
249
|
+
const next = await orch.createContainer(makeRequest('c2'));
|
|
250
|
+
// (we don't assert the same port — just that allocation works post-release)
|
|
251
|
+
expect(next.healthEndpoint).toMatch(/:\d+\/health/);
|
|
252
|
+
expect(port).toBeGreaterThan(0);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('HostProcessOrchestrator.getStats', () => {
|
|
257
|
+
it('returns plausible numbers with zero network counters', async () => {
|
|
258
|
+
const orch = makeOrchestrator();
|
|
259
|
+
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
260
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
261
|
+
// Give the child a moment to use some CPU/mem
|
|
262
|
+
await sleep(200);
|
|
263
|
+
|
|
264
|
+
const stats = await orch.getStats(dockerId);
|
|
265
|
+
expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
|
|
266
|
+
expect(stats.memoryBytes).toBeGreaterThan(0);
|
|
267
|
+
expect(stats.networkRxBytes).toBe(0);
|
|
268
|
+
expect(stats.networkTxBytes).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('HostProcessOrchestrator log rotation at spawn', () => {
|
|
273
|
+
it('rotates an oversized node.log before the new child opens it', async () => {
|
|
274
|
+
const rootDir = join(tmpRoot, 'rot');
|
|
275
|
+
mkdirSync(rootDir, { recursive: true });
|
|
276
|
+
const containerWorkdir = join(rootDir, 'rotc1');
|
|
277
|
+
mkdirSync(containerWorkdir, { recursive: true });
|
|
278
|
+
// Pre-seed an oversized active log so maybeRotateAtSpawn fires.
|
|
279
|
+
const seed = 'X'.repeat(2048);
|
|
280
|
+
writeFileSync(join(containerWorkdir, 'node.log'), seed, 'utf8');
|
|
281
|
+
|
|
282
|
+
const orch = makeOrchestrator({
|
|
283
|
+
rootDir,
|
|
284
|
+
logMaxBytes: 1024,
|
|
285
|
+
logMaxFiles: 3,
|
|
286
|
+
});
|
|
287
|
+
const { dockerId } = await orch.createContainer(makeRequest('rotc1'));
|
|
288
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
289
|
+
|
|
290
|
+
// The rotation cascade should have moved the seeded contents to .1
|
|
291
|
+
// and the fresh child should be writing to a brand-new node.log.
|
|
292
|
+
expect(readFileSync(join(containerWorkdir, 'node.log.1'), 'utf8')).toBe(seed);
|
|
293
|
+
expect(statSync(join(containerWorkdir, 'node.log')).size).toBeLessThan(seed.length);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('HostProcessOrchestrator.getLogs', () => {
|
|
298
|
+
it('returns recent stdout lines from the child', async () => {
|
|
299
|
+
const orch = makeOrchestrator();
|
|
300
|
+
process.env.CHILD_INITIAL_MESSAGE = 'hello-from-fake';
|
|
301
|
+
let dockerId: string;
|
|
302
|
+
try {
|
|
303
|
+
({ dockerId } = await orch.createContainer(makeRequest('c1')));
|
|
304
|
+
} finally {
|
|
305
|
+
delete process.env.CHILD_INITIAL_MESSAGE;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
await waitFor(async () => {
|
|
309
|
+
const logs = await orch.getLogs(dockerId, 200);
|
|
310
|
+
return logs.includes('hello-from-fake');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const logs = await orch.getLogs(dockerId, 200);
|
|
314
|
+
expect(logs).toContain('hello-from-fake');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('rotateOnDisk', () => {
|
|
319
|
+
it('cascades base -> .1 -> ... and caps at maxFiles', async () => {
|
|
320
|
+
const fs = await import('node:fs');
|
|
321
|
+
const dir = join(tmpRoot, 'logs');
|
|
322
|
+
mkdirSync(dir, { recursive: true });
|
|
323
|
+
const path = join(dir, 'node.log');
|
|
324
|
+
|
|
325
|
+
// Seed: base + .1 + .2 + .3 each with distinguishable content.
|
|
326
|
+
writeFileSync(path, 'base', 'utf8');
|
|
327
|
+
writeFileSync(`${path}.1`, 'one', 'utf8');
|
|
328
|
+
writeFileSync(`${path}.2`, 'two', 'utf8');
|
|
329
|
+
writeFileSync(`${path}.3`, 'three', 'utf8');
|
|
330
|
+
|
|
331
|
+
rotateOnDisk(path, 3);
|
|
332
|
+
|
|
333
|
+
// Active path is gone (rotated away). .1 has former base, .2 has former
|
|
334
|
+
// .1, .3 has former .2; former .3 is dropped because maxFiles=3.
|
|
335
|
+
expect(fs.existsSync(path)).toBe(false);
|
|
336
|
+
expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('base');
|
|
337
|
+
expect(fs.readFileSync(`${path}.2`, 'utf8')).toBe('one');
|
|
338
|
+
expect(fs.readFileSync(`${path}.3`, 'utf8')).toBe('two');
|
|
339
|
+
expect(fs.existsSync(`${path}.4`)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('is a no-op when the active file does not exist', () => {
|
|
343
|
+
const dir = join(tmpRoot, 'logs-empty');
|
|
344
|
+
mkdirSync(dir, { recursive: true });
|
|
345
|
+
const path = join(dir, 'node.log');
|
|
346
|
+
expect(() => rotateOnDisk(path, 3)).not.toThrow();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('handles a partial cascade (only base present)', async () => {
|
|
350
|
+
const fs = await import('node:fs');
|
|
351
|
+
const dir = join(tmpRoot, 'logs-partial');
|
|
352
|
+
mkdirSync(dir, { recursive: true });
|
|
353
|
+
const path = join(dir, 'node.log');
|
|
354
|
+
writeFileSync(path, 'only', 'utf8');
|
|
355
|
+
|
|
356
|
+
rotateOnDisk(path, 5);
|
|
357
|
+
|
|
358
|
+
expect(fs.existsSync(path)).toBe(false);
|
|
359
|
+
expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('only');
|
|
360
|
+
expect(fs.existsSync(`${path}.2`)).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('PortAllocator', () => {
|
|
365
|
+
it('allocates sequentially, releases, and reuses', () => {
|
|
366
|
+
const a = new PortAllocator(100, 102);
|
|
367
|
+
expect(a.allocate()).toBe(100);
|
|
368
|
+
expect(a.allocate()).toBe(101);
|
|
369
|
+
expect(a.allocate()).toBe(102);
|
|
370
|
+
expect(() => a.allocate()).toThrow(/No available ports/);
|
|
371
|
+
a.release(101);
|
|
372
|
+
expect(a.allocate()).toBe(101);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('markUsed reserves ports without allocating', () => {
|
|
376
|
+
const a = new PortAllocator(100, 102);
|
|
377
|
+
a.markUsed(101);
|
|
378
|
+
expect(a.allocate()).toBe(100);
|
|
379
|
+
expect(a.allocate()).toBe(102);
|
|
380
|
+
expect(() => a.allocate()).toThrow();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('Restart recovery (init)', () => {
|
|
385
|
+
it('re-attaches to surviving children and reports dead ones', async () => {
|
|
386
|
+
const rootDir = join(tmpRoot, 'restart');
|
|
387
|
+
const a = makeOrchestrator({ rootDir });
|
|
388
|
+
const r1 = await a.createContainer(makeRequest('c1'));
|
|
389
|
+
const r2 = await a.createContainer(makeRequest('c2'));
|
|
390
|
+
await waitFor(() => a.isRunning(r1.dockerId));
|
|
391
|
+
await waitFor(() => a.isRunning(r2.dockerId));
|
|
392
|
+
|
|
393
|
+
// Kill c2 externally
|
|
394
|
+
const { pid: pid2 } = decodeDockerId(r2.dockerId);
|
|
395
|
+
process.kill(pid2, 'SIGKILL');
|
|
396
|
+
await waitFor(() => !isPidAlive(pid2));
|
|
397
|
+
|
|
398
|
+
// New orchestrator over the same rootDir, init()
|
|
399
|
+
const b = makeOrchestrator({ rootDir });
|
|
400
|
+
await b.init();
|
|
401
|
+
|
|
402
|
+
expect(await b.isRunning(r1.dockerId)).toBe(true);
|
|
403
|
+
expect(await b.isRunning(r2.dockerId)).toBe(false);
|
|
404
|
+
|
|
405
|
+
const dead = b.listDeadHandles();
|
|
406
|
+
expect(dead.map((d) => d.dockerId)).toContain(r2.dockerId);
|
|
407
|
+
expect(dead.map((d) => d.dockerId)).not.toContain(r1.dockerId);
|
|
408
|
+
|
|
409
|
+
// Ports from both should be reserved (no overlapping allocations from `b`)
|
|
410
|
+
// Allocate three more containers via `b` and confirm none collide with
|
|
411
|
+
// c1/c2's existing ports.
|
|
412
|
+
const reservedPorts = new Set([
|
|
413
|
+
r1.p2pPort,
|
|
414
|
+
r2.p2pPort,
|
|
415
|
+
Number(r1.healthEndpoint.match(/:(\d+)/)![1]),
|
|
416
|
+
Number(r1.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
417
|
+
Number(r2.healthEndpoint.match(/:(\d+)/)![1]),
|
|
418
|
+
Number(r2.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
419
|
+
]);
|
|
420
|
+
const r3 = await b.createContainer(makeRequest('c3'));
|
|
421
|
+
const r3Ports = new Set([
|
|
422
|
+
r3.p2pPort,
|
|
423
|
+
Number(r3.healthEndpoint.match(/:(\d+)/)![1]),
|
|
424
|
+
Number(r3.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
425
|
+
]);
|
|
426
|
+
for (const p of r3Ports) {
|
|
427
|
+
expect(reservedPorts.has(p)).toBe(false);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The orchestrator must spawn children that survive the spawning process
|
|
434
|
+
* itself exiting. The same-process restart test below does not exercise
|
|
435
|
+
* this — both orchestrators live in the same vitest worker, so the
|
|
436
|
+
* spawning process's pipe-read fds never close. This test genuinely exits
|
|
437
|
+
* the spawning process and asserts that the grandchild (a) stays alive
|
|
438
|
+
* past the parent's death and (b) keeps writing to its log file.
|
|
439
|
+
*
|
|
440
|
+
* Requires `yarn workspace @serfab/cadre-host build` to have been run —
|
|
441
|
+
* the helper script imports the built dist so it can run as a plain Node
|
|
442
|
+
* process without a TS loader.
|
|
443
|
+
*/
|
|
444
|
+
describe('child survives orchestrator exit', () => {
|
|
445
|
+
const orchestratorDistUrl = (() => {
|
|
446
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
447
|
+
const distDir = resolvePath(here, '..', '..', 'dist', 'orchestrator');
|
|
448
|
+
const distPath = join(distDir, 'host-process-orchestrator.js');
|
|
449
|
+
return {
|
|
450
|
+
distPath,
|
|
451
|
+
orchestratorUrl: pathToFileURL(distPath).href,
|
|
452
|
+
logRotatorUrl: pathToFileURL(join(distDir, 'log-rotator.js')).href,
|
|
453
|
+
};
|
|
454
|
+
})();
|
|
455
|
+
|
|
456
|
+
it.skipIf(!existsSync(orchestratorDistUrl.distPath))(
|
|
457
|
+
'grandchild stays alive and keeps logging after spawner process exits',
|
|
458
|
+
async () => {
|
|
459
|
+
const { orchestratorUrl, logRotatorUrl } = orchestratorDistUrl;
|
|
460
|
+
const rootDir = join(tmpRoot, 'cross');
|
|
461
|
+
mkdirSync(rootDir, { recursive: true });
|
|
462
|
+
|
|
463
|
+
const helperPath = join(tmpRoot, 'spawner.mjs');
|
|
464
|
+
writeFileSync(helperPath, buildSpawnerScript(orchestratorUrl, logRotatorUrl), 'utf8');
|
|
465
|
+
|
|
466
|
+
const request = {
|
|
467
|
+
containerId: 'cross1',
|
|
468
|
+
partyId: 'party-cross1',
|
|
469
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001'],
|
|
470
|
+
profile: 'transaction',
|
|
471
|
+
};
|
|
472
|
+
const payload = {
|
|
473
|
+
rootDir,
|
|
474
|
+
entrypoint: scriptPath,
|
|
475
|
+
request,
|
|
476
|
+
// Ensure the fake child keeps ticking quickly so logs grow visibly
|
|
477
|
+
// in the ~300 ms observation window.
|
|
478
|
+
childEnv: { CHILD_TICK_MS: '20', CHILD_TICK_PAYLOAD: 'cross-tick' },
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Run the helper as a normal (non-detached) Node child and wait for
|
|
482
|
+
// it to exit. The orchestrator's grandchild should survive.
|
|
483
|
+
const result = spawnSync(process.execPath, [helperPath, JSON.stringify(payload)], {
|
|
484
|
+
encoding: 'utf8',
|
|
485
|
+
timeout: 15000,
|
|
486
|
+
});
|
|
487
|
+
expect(result.status, `helper exited non-zero: stderr=${result.stderr}`).toBe(0);
|
|
488
|
+
|
|
489
|
+
const jsonLine = result.stdout
|
|
490
|
+
.split(/\r?\n/)
|
|
491
|
+
.reverse()
|
|
492
|
+
.find((line) => line.trim().startsWith('{'));
|
|
493
|
+
expect(jsonLine, `helper produced no JSON line: stdout=${result.stdout}`).toBeTruthy();
|
|
494
|
+
const handle = JSON.parse(jsonLine!) as {
|
|
495
|
+
dockerId: string;
|
|
496
|
+
pid: number;
|
|
497
|
+
workdir: string;
|
|
498
|
+
logPath: string;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
// Immediate liveness
|
|
503
|
+
expect(isPidAlive(handle.pid)).toBe(true);
|
|
504
|
+
|
|
505
|
+
// POSIX delivers SIGPIPE on the child's NEXT write — give it time
|
|
506
|
+
// to attempt one (the fake child ticks every 20 ms).
|
|
507
|
+
await sleep(500);
|
|
508
|
+
expect(isPidAlive(handle.pid), 'grandchild died after spawner exit (likely SIGPIPE)').toBe(true);
|
|
509
|
+
|
|
510
|
+
// Logs must keep growing — proves stdio still works, not just PID.
|
|
511
|
+
const sizeA = statSync(handle.logPath).size;
|
|
512
|
+
await sleep(300);
|
|
513
|
+
const sizeB = statSync(handle.logPath).size;
|
|
514
|
+
expect(sizeB).toBeGreaterThan(sizeA);
|
|
515
|
+
} finally {
|
|
516
|
+
try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
517
|
+
try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
function buildSpawnerScript(orchestratorUrl: string, logRotatorUrl: string): string {
|
|
524
|
+
return `
|
|
525
|
+
import { HostProcessOrchestrator } from ${JSON.stringify(orchestratorUrl)};
|
|
526
|
+
import { defaultLogPath } from ${JSON.stringify(logRotatorUrl)};
|
|
527
|
+
|
|
528
|
+
const payload = JSON.parse(process.argv[2]);
|
|
529
|
+
const { rootDir, entrypoint, request, childEnv } = payload;
|
|
530
|
+
|
|
531
|
+
for (const [k, v] of Object.entries(childEnv ?? {})) {
|
|
532
|
+
process.env[k] = String(v);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const orch = new HostProcessOrchestrator({
|
|
536
|
+
rootDir,
|
|
537
|
+
portRange: { start: 19000, end: 19999 },
|
|
538
|
+
stopTimeoutMs: 1500,
|
|
539
|
+
spawn: { entrypoint },
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const res = await orch.createContainer(request);
|
|
543
|
+
const pid = Number(res.dockerId.split(':')[0]);
|
|
544
|
+
const workdir = (await import('node:path')).join(rootDir, request.containerId);
|
|
545
|
+
const logPath = defaultLogPath(workdir);
|
|
546
|
+
|
|
547
|
+
process.stdout.write(JSON.stringify({
|
|
548
|
+
dockerId: res.dockerId,
|
|
549
|
+
pid,
|
|
550
|
+
workdir,
|
|
551
|
+
logPath,
|
|
552
|
+
}) + '\\n');
|
|
553
|
+
|
|
554
|
+
// Exit cleanly — the detached/unref'd child should outlive us.
|
|
555
|
+
process.exit(0);
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
describe('dockerId encoding', () => {
|
|
560
|
+
it('encode/decode round-trips pid and token', () => {
|
|
561
|
+
const id = encodeDockerId(12345, 'abcdef');
|
|
562
|
+
expect(decodeDockerId(id)).toEqual({ pid: 12345, token: 'abcdef' });
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('rejects malformed ids', () => {
|
|
566
|
+
expect(() => decodeDockerId('notvalid')).toThrow();
|
|
567
|
+
expect(() => decodeDockerId(':oops')).toThrow();
|
|
568
|
+
expect(() => decodeDockerId('0:tok')).toThrow();
|
|
569
|
+
});
|
|
570
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseDuration } from '../duration.js';
|
|
4
|
+
|
|
5
|
+
describe('parseDuration', () => {
|
|
6
|
+
it('parses common units', () => {
|
|
7
|
+
expect(parseDuration('500ms')).toBe(500);
|
|
8
|
+
expect(parseDuration('30s')).toBe(30_000);
|
|
9
|
+
expect(parseDuration('5m')).toBe(5 * 60 * 1000);
|
|
10
|
+
expect(parseDuration('24h')).toBe(24 * 60 * 60 * 1000);
|
|
11
|
+
expect(parseDuration('7d')).toBe(7 * 24 * 60 * 60 * 1000);
|
|
12
|
+
expect(parseDuration('2w')).toBe(2 * 7 * 24 * 60 * 60 * 1000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('treats bare numbers as milliseconds', () => {
|
|
16
|
+
expect(parseDuration('1234')).toBe(1234);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('accepts whitespace and decimals', () => {
|
|
20
|
+
expect(parseDuration(' 1.5h ')).toBe(Math.round(1.5 * 60 * 60 * 1000));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('throws on garbage input', () => {
|
|
24
|
+
expect(() => parseDuration('abc')).toThrow();
|
|
25
|
+
expect(() => parseDuration('1y')).toThrow();
|
|
26
|
+
expect(() => parseDuration('')).toThrow();
|
|
27
|
+
});
|
|
28
|
+
});
|