@serfab/cadre-host 0.11.0 → 0.13.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 +406 -296
- package/dist/bin/host.d.ts +11 -7
- package/dist/bin/host.js +40 -12
- package/dist/bin/host.js.map +1 -1
- package/dist/installer/config.d.ts +4 -4
- package/dist/installer/config.js +5 -39
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/identity.d.ts +8 -1
- package/dist/installer/identity.js +8 -1
- package/dist/installer/identity.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
- package/dist/orchestrator/host-process-orchestrator.js +14 -4
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +96 -0
- package/dist/server/routes/bootstrap-node-validation.js +178 -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/settings-store.d.ts +1 -1
- package/dist/server/settings-store.js +5 -1
- package/dist/server/settings-store.js.map +1 -1
- package/package.json +5 -4
- package/src/__tests__/cli.smoke.test.ts +8 -3
- package/src/__tests__/global-setup.ts +9 -4
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/bin/__tests__/cli-reference.test.ts +167 -0
- package/src/bin/host.ts +1326 -1299
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/installer/__tests__/config.test.ts +134 -112
- package/src/installer/config.ts +5 -43
- package/src/installer/identity.ts +8 -1
- package/src/nat/__tests__/nat-service.test.ts +515 -497
- package/src/orchestrator/host-process-orchestrator.ts +1143 -1133
- package/src/server/__tests__/bootstrap-node-validation.test.ts +134 -0
- package/src/server/__tests__/grants-route.test.ts +326 -263
- package/src/server/__tests__/settings-route.test.ts +8 -7
- package/src/server/routes/bootstrap-node-validation.ts +187 -0
- package/src/server/routes/grants.ts +9 -26
- package/src/server/routes/provision-request-validation.ts +105 -0
- package/src/server/settings-store.ts +41 -37
|
@@ -1,951 +1,951 @@
|
|
|
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 { allocateNodePorts, 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
|
-
import { loadIdentity } from '../installer/identity.js';
|
|
23
|
-
|
|
24
|
-
const FAKE_CHILD = `
|
|
25
|
-
import fs from 'node:fs';
|
|
26
|
-
import path from 'node:path';
|
|
27
|
-
|
|
28
|
-
const args = process.argv.slice(2);
|
|
29
|
-
const configIdx = args.indexOf('-c');
|
|
30
|
-
const tokenIdx = args.indexOf('--startup-token-file');
|
|
31
|
-
const tokenPath = tokenIdx >= 0 ? args[tokenIdx + 1] : null;
|
|
32
|
-
const ignoreSigterm = process.env.IGNORE_SIGTERM === '1';
|
|
33
|
-
const initialMessage = process.env.CHILD_INITIAL_MESSAGE ?? 'tick';
|
|
34
|
-
const tickIntervalMs = parseInt(process.env.CHILD_TICK_MS ?? '50', 10);
|
|
35
|
-
const tickPayload = process.env.CHILD_TICK_PAYLOAD ?? 'tick';
|
|
36
|
-
|
|
37
|
-
if (tokenPath && process.env.CADRE_STARTUP_TOKEN) {
|
|
38
|
-
try {
|
|
39
|
-
fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN, 'utf8');
|
|
40
|
-
} catch (err) {
|
|
41
|
-
console.error('failed to write token:', err);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
let alive = true;
|
|
46
|
-
process.on('SIGTERM', () => {
|
|
47
|
-
if (ignoreSigterm) {
|
|
48
|
-
console.log('ignoring SIGTERM');
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
alive = false;
|
|
52
|
-
console.log('exiting on SIGTERM');
|
|
53
|
-
process.exit(0);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
console.log(initialMessage);
|
|
57
|
-
|
|
58
|
-
const interval = setInterval(() => {
|
|
59
|
-
if (alive) {
|
|
60
|
-
process.stdout.write(tickPayload + '\\n');
|
|
61
|
-
} else {
|
|
62
|
-
clearInterval(interval);
|
|
63
|
-
}
|
|
64
|
-
}, tickIntervalMs);
|
|
65
|
-
|
|
66
|
-
setInterval(() => {}, 1 << 30);
|
|
67
|
-
`;
|
|
68
|
-
|
|
69
|
-
let tmpRoot: string;
|
|
70
|
-
let scriptPath: string;
|
|
71
|
-
const orchestrators: HostProcessOrchestrator[] = [];
|
|
72
|
-
|
|
73
|
-
beforeEach(() => {
|
|
74
|
-
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-orch-'));
|
|
75
|
-
scriptPath = join(tmpRoot, 'fake-child.mjs');
|
|
76
|
-
writeFileSync(scriptPath, FAKE_CHILD, 'utf8');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
afterEach(async () => {
|
|
80
|
-
// Force-kill any leftover children before cleanup.
|
|
81
|
-
for (const orch of orchestrators) {
|
|
82
|
-
for (const dockerId of listDockerIds(orch)) {
|
|
83
|
-
try { await orch.removeContainer(dockerId); } catch { /* ignore */ }
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
orchestrators.length = 0;
|
|
87
|
-
await sleep(50);
|
|
88
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
|
|
92
|
-
const rootDir = overrides.rootDir ?? join(tmpRoot, 'orch');
|
|
93
|
-
mkdirSync(rootDir, { recursive: true });
|
|
94
|
-
const orch = new HostProcessOrchestrator({
|
|
95
|
-
rootDir,
|
|
96
|
-
portRange: { start: 18000, end: 18999 },
|
|
97
|
-
stopTimeoutMs: 1500,
|
|
98
|
-
logMaxBytes: overrides.logMaxBytes ?? 10 * 1024 * 1024,
|
|
99
|
-
logMaxFiles: overrides.logMaxFiles ?? 5,
|
|
100
|
-
spawn: { entrypoint: overrides.spawn?.entrypoint ?? scriptPath },
|
|
101
|
-
...overrides,
|
|
102
|
-
});
|
|
103
|
-
orchestrators.push(orch);
|
|
104
|
-
return orch;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function listDockerIds(orch: HostProcessOrchestrator): string[] {
|
|
108
|
-
// Use the state file as the source of truth since handles map is private.
|
|
109
|
-
const state = new StateStore((orch as unknown as { rootDir: string }).rootDir);
|
|
110
|
-
return state.load().handles.map((h) => h.dockerId);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
|
|
114
|
-
const deadline = Date.now() + timeoutMs;
|
|
115
|
-
while (Date.now() < deadline) {
|
|
116
|
-
if (await predicate()) return;
|
|
117
|
-
await sleep(intervalMs);
|
|
118
|
-
}
|
|
119
|
-
throw new Error('waitFor: timed out');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function sleep(ms: number): Promise<void> {
|
|
123
|
-
return new Promise((res) => setTimeout(res, ms));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transaction' } = {}) {
|
|
127
|
-
return {
|
|
128
|
-
containerId,
|
|
129
|
-
partyId: 'party-' + containerId,
|
|
130
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001'],
|
|
131
|
-
profile: opts.profile ?? 'transaction' as 'storage' | 'transaction',
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
describe('HostProcessOrchestrator.createContainer', () => {
|
|
136
|
-
it('spawns a live child and persists handle, ports, and endpoints', async () => {
|
|
137
|
-
const orch = makeOrchestrator();
|
|
138
|
-
const result = await orch.createContainer(makeRequest('c1'));
|
|
139
|
-
|
|
140
|
-
// Endpoints derive from allocated ports
|
|
141
|
-
expect(result.healthEndpoint).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
142
|
-
expect(result.metricsEndpoint).toMatch(/^http:\/\/localhost:\d+\/metrics$/);
|
|
143
|
-
expect(result.p2pPort).toBeGreaterThanOrEqual(18000);
|
|
144
|
-
|
|
145
|
-
const { pid } = decodeDockerId(result.dockerId);
|
|
146
|
-
await waitFor(() => isPidAlive(pid));
|
|
147
|
-
expect(isPidAlive(pid)).toBe(true);
|
|
148
|
-
|
|
149
|
-
// Wait until the child has written its startup token file
|
|
150
|
-
await waitFor(() => orch.isRunning(result.dockerId));
|
|
151
|
-
expect(await orch.isRunning(result.dockerId)).toBe(true);
|
|
152
|
-
|
|
153
|
-
// Workdir scaffolded
|
|
154
|
-
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
155
|
-
expect(readFileSync(join(workdir, 'cadre.json'), 'utf8')).toContain('party-c1');
|
|
156
|
-
|
|
157
|
-
// State persisted
|
|
158
|
-
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
159
|
-
expect(persisted.handles).toHaveLength(1);
|
|
160
|
-
expect(persisted.handles[0]!.dockerId).toBe(result.dockerId);
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
|
|
165
|
-
// The donated-node respawn path calls createContainer again with the id of a
|
|
166
|
-
// container the host still holds a (now dead) handle for. Handles are keyed by
|
|
167
|
-
// the per-spawn dockerId, so without an explicit drop the old one would linger
|
|
168
|
-
// forever and its four ports would never come back.
|
|
169
|
-
it('drops the stale handle, releases its ports, and keeps the identity', async () => {
|
|
170
|
-
const orch = makeOrchestrator();
|
|
171
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
172
|
-
|
|
173
|
-
const first = await orch.createContainer(makeRequest('c1'));
|
|
174
|
-
await waitFor(() => orch.isRunning(first.dockerId));
|
|
175
|
-
const identityPath = join(rootDir, 'c1', 'identity.key');
|
|
176
|
-
const peerBefore = loadIdentity(identityPath).peerId;
|
|
177
|
-
|
|
178
|
-
// Kill the child the way a crash would — the handle survives, still marked
|
|
179
|
-
// alive until something notices.
|
|
180
|
-
const { pid: firstPid } = decodeDockerId(first.dockerId);
|
|
181
|
-
process.kill(firstPid, 'SIGKILL');
|
|
182
|
-
await waitFor(() => !isPidAlive(firstPid));
|
|
183
|
-
|
|
184
|
-
const second = await orch.createContainer(makeRequest('c1'));
|
|
185
|
-
|
|
186
|
-
// Exactly one handle for c1, and it is the new spawn.
|
|
187
|
-
const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
|
|
188
|
-
expect(handles).toHaveLength(1);
|
|
189
|
-
expect(handles[0]!.dockerId).toBe(second.dockerId);
|
|
190
|
-
|
|
191
|
-
// The allocator hands back the lowest free port, so re-using the first
|
|
192
|
-
// spawn's exact ports is what proves they were released.
|
|
193
|
-
expect(second.p2pPort).toBe(first.p2pPort);
|
|
194
|
-
expect(second.healthEndpoint).toBe(first.healthEndpoint);
|
|
195
|
-
|
|
196
|
-
// Same workdir, same key → same peer id. This is what makes a respawn the
|
|
197
|
-
// same node rather than a new one the borrower's cadre has never approved.
|
|
198
|
-
expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* A launch that fails must leave host state exactly as it found it. The drop of
|
|
204
|
-
* the previous handle happens before the child is launched, so without an
|
|
205
|
-
* explicit restore a failed re-spawn would leave the donation record naming a
|
|
206
|
-
* dockerId the orchestrator can no longer resolve — and the node's workdir
|
|
207
|
-
* (identity key, node-local stores, storage) stranded on disk with nothing able
|
|
208
|
-
* to delete it.
|
|
209
|
-
*
|
|
210
|
-
* Inducing a launch failure portably: replace the workdir's `storage`
|
|
211
|
-
* directory with a plain file. `mkdirSync(path, { recursive: true })` throws
|
|
212
|
-
* EEXIST when the path exists as a non-directory on every platform, and it is
|
|
213
|
-
* the second statement in `launchChild` — after the handle drop, before the
|
|
214
|
-
* spawn.
|
|
215
|
-
*/
|
|
216
|
-
describe('HostProcessOrchestrator failed launch', () => {
|
|
217
|
-
/** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
|
|
218
|
-
function sabotageWorkdir(workdir: string): string {
|
|
219
|
-
const storage = join(workdir, 'storage');
|
|
220
|
-
rmSync(storage, { recursive: true, force: true });
|
|
221
|
-
writeFileSync(storage, 'not-a-directory', 'utf8');
|
|
222
|
-
return storage;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
it('keeps the prior handle addressable, so a later terminate still reclaims the workdir', async () => {
|
|
226
|
-
const orch = makeOrchestrator();
|
|
227
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
228
|
-
|
|
229
|
-
const first = await orch.createContainer(makeRequest('c1'));
|
|
230
|
-
await waitFor(() => orch.isRunning(first.dockerId));
|
|
231
|
-
await orch.stopContainer(first.dockerId);
|
|
232
|
-
|
|
233
|
-
const workdir = join(rootDir, 'c1');
|
|
234
|
-
sabotageWorkdir(workdir);
|
|
235
|
-
|
|
236
|
-
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/EEXIST|exists/i);
|
|
237
|
-
|
|
238
|
-
// The record's dockerId still resolves, and no ghost handle was added.
|
|
239
|
-
expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
|
|
240
|
-
expect(orch.resolveDockerId('c1')).toBe(first.dockerId);
|
|
241
|
-
expect(orch.listNodes()).toHaveLength(1);
|
|
242
|
-
|
|
243
|
-
// Which is the whole point: cleanup can still find and delete the node.
|
|
244
|
-
await orch.removeContainer(first.dockerId);
|
|
245
|
-
expect(existsSync(workdir)).toBe(false);
|
|
246
|
-
expect(orch.listNodes()).toHaveLength(0);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('does not shift the ports a successful retry comes back on', async () => {
|
|
250
|
-
const orch = makeOrchestrator();
|
|
251
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
252
|
-
|
|
253
|
-
const first = await orch.createContainer(makeRequest('c1'));
|
|
254
|
-
await waitFor(() => orch.isRunning(first.dockerId));
|
|
255
|
-
await orch.stopContainer(first.dockerId);
|
|
256
|
-
|
|
257
|
-
const workdir = join(rootDir, 'c1');
|
|
258
|
-
const storage = sabotageWorkdir(workdir);
|
|
259
|
-
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
|
|
260
|
-
|
|
261
|
-
rmSync(storage, { force: true });
|
|
262
|
-
const second = await orch.createContainer(makeRequest('c1'));
|
|
263
|
-
|
|
264
|
-
// The restored handle held its four ports across the failure, and the
|
|
265
|
-
// allocator hands back the lowest free port — so identical ports prove the
|
|
266
|
-
// failed attempt neither leaked nor stole them.
|
|
267
|
-
expect(second.p2pPort).toBe(first.p2pPort);
|
|
268
|
-
expect(second.healthEndpoint).toBe(first.healthEndpoint);
|
|
269
|
-
expect(second.metricsEndpoint).toBe(first.metricsEndpoint);
|
|
270
|
-
|
|
271
|
-
// And the successful spawn's own drop still stands: exactly one handle.
|
|
272
|
-
const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
|
|
273
|
-
expect(handles).toHaveLength(1);
|
|
274
|
-
expect(handles[0]!.dockerId).toBe(second.dockerId);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it('releases every port a failed FIRST spawn reserved', async () => {
|
|
278
|
-
const orch = makeOrchestrator({ portRange: { start: 12000, end: 12100 } });
|
|
279
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
280
|
-
|
|
281
|
-
await orch.createContainer(makeRequest('c1'));
|
|
282
|
-
expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003 });
|
|
283
|
-
|
|
284
|
-
// No prior handle to restore here — the four ports are simply released.
|
|
285
|
-
mkdirSync(join(rootDir, 'c2'), { recursive: true });
|
|
286
|
-
writeFileSync(join(rootDir, 'c2', 'storage'), 'not-a-directory', 'utf8');
|
|
287
|
-
await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow();
|
|
288
|
-
|
|
289
|
-
await orch.createContainer(makeRequest('c3'));
|
|
290
|
-
// With the leak these would start at 12008.
|
|
291
|
-
expect(orch.getNode('c3')!.ports).toEqual({ health: 12004, metrics: 12005, p2p: 12006, admin: 12007 });
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it('leaves the owner node addressable on its original ports', async () => {
|
|
295
|
-
const orch = makeOrchestrator();
|
|
296
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
297
|
-
const ownerConfig = {
|
|
298
|
-
identityPath: join(tmpRoot, 'identity.key'),
|
|
299
|
-
partyId: 'party-owner',
|
|
300
|
-
libp2pPort: 14001,
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const owner = await orch.ensureOwnerNode(ownerConfig);
|
|
304
|
-
await waitFor(() => orch.isRunning(owner.dockerId));
|
|
305
|
-
const endpointBefore = orch.getOwnerAdminEndpoint();
|
|
306
|
-
|
|
307
|
-
// ensureOwnerNode short-circuits on a live child, so kill it first.
|
|
308
|
-
const { pid } = decodeDockerId(owner.dockerId);
|
|
309
|
-
process.kill(pid, 'SIGKILL');
|
|
310
|
-
await waitFor(() => !isPidAlive(pid));
|
|
311
|
-
|
|
312
|
-
const storage = sabotageWorkdir(join(rootDir, 'owner'));
|
|
313
|
-
await expect(orch.ensureOwnerNode()).rejects.toThrow();
|
|
314
|
-
|
|
315
|
-
// The admin channel still resolves — a manager that lost this endpoint
|
|
316
|
-
// could no longer delegate to its own node.
|
|
317
|
-
expect(orch.getOwnerAdminEndpoint()).toEqual(endpointBefore);
|
|
318
|
-
expect(orch.listNodes().some((n) => n.dockerId === owner.dockerId)).toBe(true);
|
|
319
|
-
|
|
320
|
-
rmSync(storage, { force: true });
|
|
321
|
-
const respawned = await orch.ensureOwnerNode();
|
|
322
|
-
expect(respawned.ports).toEqual(owner.ports);
|
|
323
|
-
expect(orch.listNodes().filter((n) => n.owner)).toHaveLength(1);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// The caller's unwind is only correct for failures that happen BEFORE the
|
|
327
|
-
// child exists. A state.json write failure happens after it — if that were
|
|
328
|
-
// allowed to propagate, the unwind would release the ports the live child is
|
|
329
|
-
// bound to and put the replaced handle back beside it.
|
|
330
|
-
it('does not unwind a launch that already spawned when the state write fails', async () => {
|
|
331
|
-
const orch = makeOrchestrator();
|
|
332
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
333
|
-
const first = await orch.createContainer(makeRequest('c1'));
|
|
334
|
-
await waitFor(() => orch.isRunning(first.dockerId));
|
|
335
|
-
await orch.stopContainer(first.dockerId);
|
|
336
|
-
|
|
337
|
-
const stateStore = (orch as unknown as { stateStore: StateStore }).stateStore;
|
|
338
|
-
const save = stateStore.save.bind(stateStore);
|
|
339
|
-
stateStore.save = () => { throw new Error('ENOSPC: no space left on device'); };
|
|
340
|
-
const second = await orch.createContainer(makeRequest('c1'));
|
|
341
|
-
stateStore.save = save;
|
|
342
|
-
|
|
343
|
-
// One handle for the container, and it is the new one.
|
|
344
|
-
expect(orch.listNodes()).toHaveLength(1);
|
|
345
|
-
expect(orch.getNode('c1')?.dockerId).toBe(second.dockerId);
|
|
346
|
-
|
|
347
|
-
// And the workdir the live child is running in is untouched — a swallowed
|
|
348
|
-
// persist must not reach the unwind that would delete it.
|
|
349
|
-
expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
|
|
350
|
-
|
|
351
|
-
// Its ports are still held: a fresh container gets four different ones.
|
|
352
|
-
const other = await orch.createContainer(makeRequest('c2'));
|
|
353
|
-
expect(other.p2pPort).not.toBe(second.p2pPort);
|
|
354
|
-
expect(orch.getNode('c2')!.ports).not.toEqual(orch.getNode('c1')!.ports);
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// A spawn that CREATED the workdir must take it back down on the way out —
|
|
358
|
-
// the record it was provisioning never got a dockerId, so nothing else can
|
|
359
|
-
// ever name that directory. The storage-file sabotage above is unusable here:
|
|
360
|
-
// pre-creating `<workdir>/storage` makes the directory pre-existing, and the
|
|
361
|
-
// unwind then correctly declines to delete it. Exhausting the port range
|
|
362
|
-
// fails the very next step instead, with the workdir genuinely fresh.
|
|
363
|
-
it('discards the workdir a failed first spawn created', async () => {
|
|
364
|
-
// Exactly four ports — enough for one node and no more.
|
|
365
|
-
const orch = makeOrchestrator({ portRange: { start: 13000, end: 13003 } });
|
|
366
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
367
|
-
|
|
368
|
-
await orch.createContainer(makeRequest('c1'));
|
|
369
|
-
await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow(/No available ports/);
|
|
370
|
-
|
|
371
|
-
// `ensureNodeIdentity` had already created it and written the key.
|
|
372
|
-
expect(existsSync(join(rootDir, 'c2'))).toBe(false);
|
|
373
|
-
// The first node is untouched.
|
|
374
|
-
expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
|
|
375
|
-
expect(orch.listNodes()).toHaveLength(1);
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
// The load-bearing case in the other direction: a re-spawn is REUSING the
|
|
379
|
-
// directory, and the identity key inside it is the whole reason the node
|
|
380
|
-
// comes back as the same peer the borrower's cadre approved.
|
|
381
|
-
it('keeps the workdir when a re-spawn launch fails', async () => {
|
|
382
|
-
const orch = makeOrchestrator();
|
|
383
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
384
|
-
|
|
385
|
-
const first = await orch.createContainer(makeRequest('c1'));
|
|
386
|
-
await waitFor(() => orch.isRunning(first.dockerId));
|
|
387
|
-
await orch.stopContainer(first.dockerId);
|
|
388
|
-
|
|
389
|
-
const workdir = join(rootDir, 'c1');
|
|
390
|
-
const identityPath = join(workdir, 'identity.key');
|
|
391
|
-
const peerBefore = loadIdentity(identityPath).peerId;
|
|
392
|
-
|
|
393
|
-
const storage = sabotageWorkdir(workdir);
|
|
394
|
-
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
|
|
395
|
-
|
|
396
|
-
expect(existsSync(workdir)).toBe(true);
|
|
397
|
-
expect(existsSync(identityPath)).toBe(true);
|
|
398
|
-
|
|
399
|
-
// Which is what makes the next attempt the SAME node, not a stranger.
|
|
400
|
-
rmSync(storage, { force: true });
|
|
401
|
-
await orch.createContainer(makeRequest('c1'));
|
|
402
|
-
expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
// `ensureNodeIdentity` now runs inside the same `try`, so its own failures
|
|
406
|
-
// unwind too — and this one must NOT delete anything. The directory was
|
|
407
|
-
// pre-existing, so the undecodable key is an operator-visible fault to
|
|
408
|
-
// investigate, not this attempt's mess to clear up.
|
|
409
|
-
it('keeps a pre-existing workdir whose identity key is undecodable', async () => {
|
|
410
|
-
const orch = makeOrchestrator();
|
|
411
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
412
|
-
const workdir = join(rootDir, 'c1');
|
|
413
|
-
|
|
414
|
-
mkdirSync(workdir, { recursive: true });
|
|
415
|
-
writeFileSync(join(workdir, 'identity.key'), Buffer.from([0xff, 0xff, 0xff, 0xff]));
|
|
416
|
-
|
|
417
|
-
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
|
|
418
|
-
|
|
419
|
-
expect(existsSync(workdir)).toBe(true);
|
|
420
|
-
expect(existsSync(join(workdir, 'identity.key'))).toBe(true);
|
|
421
|
-
expect(orch.listNodes()).toHaveLength(0);
|
|
422
|
-
});
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* The cleanup of last resort: a donation record that never got a `dockerId`
|
|
427
|
-
* because the host died mid-spawn. `createContainer`'s own unwind cannot help —
|
|
428
|
-
* it was never reached — so the reap falls back to reclaiming by container name.
|
|
429
|
-
*/
|
|
430
|
-
describe('HostProcessOrchestrator.reclaimWorkdir', () => {
|
|
431
|
-
it('removes an orphaned workdir no handle owns', async () => {
|
|
432
|
-
const orch = makeOrchestrator();
|
|
433
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
434
|
-
const workdir = join(rootDir, 'grn_orphan');
|
|
435
|
-
mkdirSync(workdir, { recursive: true });
|
|
436
|
-
writeFileSync(join(workdir, 'identity.key'), 'key-bytes', 'utf8');
|
|
437
|
-
|
|
438
|
-
expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
|
|
439
|
-
expect(existsSync(workdir)).toBe(false);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
it('refuses a container a live handle still owns', async () => {
|
|
443
|
-
const orch = makeOrchestrator();
|
|
444
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
445
|
-
const created = await orch.createContainer(makeRequest('c1'));
|
|
446
|
-
|
|
447
|
-
// That directory is the live child's cwd; only `removeContainer`, which
|
|
448
|
-
// stops the child first, may delete it.
|
|
449
|
-
expect(orch.reclaimWorkdir('c1')).toBe(false);
|
|
450
|
-
expect(existsSync(join(rootDir, 'c1'))).toBe(true);
|
|
451
|
-
expect(orch.resolveDockerId('c1')).toBe(created.dockerId);
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it('refuses the owner node', async () => {
|
|
455
|
-
const orch = makeOrchestrator();
|
|
456
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
457
|
-
// No owner handle exists, so only the id check can save this directory —
|
|
458
|
-
// which holds the host's own control-DB storage.
|
|
459
|
-
const workdir = join(rootDir, 'owner');
|
|
460
|
-
mkdirSync(workdir, { recursive: true });
|
|
461
|
-
|
|
462
|
-
expect(orch.reclaimWorkdir('owner')).toBe(false);
|
|
463
|
-
expect(existsSync(workdir)).toBe(true);
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it('reports false for a workdir that is already gone', () => {
|
|
467
|
-
const orch = makeOrchestrator();
|
|
468
|
-
expect(orch.reclaimWorkdir('grn_never_existed')).toBe(false);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it('refuses an id that names a file rather than a workdir', () => {
|
|
472
|
-
const orch = makeOrchestrator();
|
|
473
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
474
|
-
// `rootDir` is not only container directories — `state.json` is the
|
|
475
|
-
// orchestrator's own persisted handle map, and `rmSync` would take it.
|
|
476
|
-
const statePath = join(rootDir, 'state.json');
|
|
477
|
-
writeFileSync(statePath, '{"handles":[]}', 'utf8');
|
|
478
|
-
|
|
479
|
-
expect(orch.reclaimWorkdir('state.json')).toBe(false);
|
|
480
|
-
expect(existsSync(statePath)).toBe(true);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
it('refuses an id that would escape rootDir', () => {
|
|
484
|
-
const orch = makeOrchestrator();
|
|
485
|
-
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
486
|
-
const sibling = join(rootDir, '..', 'sibling');
|
|
487
|
-
mkdirSync(sibling, { recursive: true });
|
|
488
|
-
|
|
489
|
-
// Unreachable with real `grn_<base64url>` ids; the guard is defence for a
|
|
490
|
-
// future caller that passes something else.
|
|
491
|
-
expect(orch.reclaimWorkdir('../sibling')).toBe(false);
|
|
492
|
-
expect(existsSync(sibling)).toBe(true);
|
|
493
|
-
});
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
describe('HostProcessOrchestrator.isRunning', () => {
|
|
497
|
-
it('is true after spawn, false after kill, false on tampered token', async () => {
|
|
498
|
-
const orch = makeOrchestrator();
|
|
499
|
-
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
500
|
-
const { pid } = decodeDockerId(dockerId);
|
|
501
|
-
|
|
502
|
-
await waitFor(() => orch.isRunning(dockerId));
|
|
503
|
-
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
504
|
-
|
|
505
|
-
// Tamper with the token file
|
|
506
|
-
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
507
|
-
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
|
-
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
514
|
-
|
|
515
|
-
process.kill(pid, 'SIGKILL');
|
|
516
|
-
await waitFor(() => !isPidAlive(pid));
|
|
517
|
-
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
518
|
-
});
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
describe('HostProcessOrchestrator.stopContainer', () => {
|
|
522
|
-
it('SIGTERM stops a cooperative child without needing SIGKILL', async () => {
|
|
523
|
-
const orch = makeOrchestrator();
|
|
524
|
-
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
525
|
-
const { pid } = decodeDockerId(dockerId);
|
|
526
|
-
|
|
527
|
-
await waitFor(() => orch.isRunning(dockerId));
|
|
528
|
-
await orch.stopContainer(dockerId);
|
|
529
|
-
|
|
530
|
-
expect(isPidAlive(pid)).toBe(false);
|
|
531
|
-
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
// On Windows, process.kill ignores the signal name and always force-kills,
|
|
535
|
-
// so a child cannot ignore SIGTERM at the Node level. Skip there.
|
|
536
|
-
it.skipIf(process.platform === 'win32')(
|
|
537
|
-
'escalates to SIGKILL when the child ignores SIGTERM',
|
|
538
|
-
async () => {
|
|
539
|
-
const orch = makeOrchestrator();
|
|
540
|
-
process.env.IGNORE_SIGTERM = '1';
|
|
541
|
-
let dockerId: string;
|
|
542
|
-
try {
|
|
543
|
-
({ dockerId } = await orch.createContainer(makeRequest('c2')));
|
|
544
|
-
} finally {
|
|
545
|
-
delete process.env.IGNORE_SIGTERM;
|
|
546
|
-
}
|
|
547
|
-
const { pid } = decodeDockerId(dockerId);
|
|
548
|
-
await waitFor(() => isPidAlive(pid));
|
|
549
|
-
|
|
550
|
-
const start = Date.now();
|
|
551
|
-
await orch.stopContainer(dockerId);
|
|
552
|
-
const elapsed = Date.now() - start;
|
|
553
|
-
|
|
554
|
-
// Should have waited the full stopTimeoutMs (1500) before SIGKILL.
|
|
555
|
-
expect(elapsed).toBeGreaterThanOrEqual(1000);
|
|
556
|
-
expect(isPidAlive(pid)).toBe(false);
|
|
557
|
-
},
|
|
558
|
-
);
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
describe('HostProcessOrchestrator.removeContainer', () => {
|
|
562
|
-
it('releases ports, deletes workdir, drops state', async () => {
|
|
563
|
-
const orch = makeOrchestrator();
|
|
564
|
-
const { dockerId, healthEndpoint } = await orch.createContainer(makeRequest('c1'));
|
|
565
|
-
const port = Number(healthEndpoint.match(/:(\d+)/)![1]);
|
|
566
|
-
|
|
567
|
-
await waitFor(() => orch.isRunning(dockerId));
|
|
568
|
-
await orch.removeContainer(dockerId);
|
|
569
|
-
|
|
570
|
-
// Workdir is gone
|
|
571
|
-
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
572
|
-
expect(() => readFileSync(join(workdir, 'cadre.json'), 'utf8')).toThrow();
|
|
573
|
-
|
|
574
|
-
// State no longer references it
|
|
575
|
-
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
576
|
-
expect(persisted.handles).toHaveLength(0);
|
|
577
|
-
|
|
578
|
-
// Subsequent getStats rejects
|
|
579
|
-
await expect(orch.getStats(dockerId)).rejects.toThrow(/not found/i);
|
|
580
|
-
|
|
581
|
-
// Port was released — reallocate it via a fresh container; pick the next id.
|
|
582
|
-
const next = await orch.createContainer(makeRequest('c2'));
|
|
583
|
-
// (we don't assert the same port — just that allocation works post-release)
|
|
584
|
-
expect(next.healthEndpoint).toMatch(/:\d+\/health/);
|
|
585
|
-
expect(port).toBeGreaterThan(0);
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* `getStats` goes through `pidusage`, which on Windows shells out: modern
|
|
591
|
-
* builds no longer ship `wmic`, so it falls back to spawning `powershell.exe`
|
|
592
|
-
* to run `gwmi win32_process` — and the first sample for a fresh pid costs
|
|
593
|
-
* *two* such spawns, because CPU percent needs a prior baseline. Measured
|
|
594
|
-
* ~2.8 s idle and ~4.9 s on a loaded machine, so the 5 s default test timeout
|
|
595
|
-
* is not a real budget for this one. Give it explicit headroom.
|
|
596
|
-
*/
|
|
597
|
-
const STATS_TIMEOUT_MS = 30_000;
|
|
598
|
-
|
|
599
|
-
describe('HostProcessOrchestrator.getStats', () => {
|
|
600
|
-
it('returns plausible numbers with zero network counters', async () => {
|
|
601
|
-
const orch = makeOrchestrator();
|
|
602
|
-
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
603
|
-
await waitFor(() => orch.isRunning(dockerId));
|
|
604
|
-
// Give the child a moment to use some CPU/mem
|
|
605
|
-
await sleep(200);
|
|
606
|
-
|
|
607
|
-
const stats = await orch.getStats(dockerId);
|
|
608
|
-
expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
|
|
609
|
-
expect(stats.memoryBytes).toBeGreaterThan(0);
|
|
610
|
-
expect(stats.networkRxBytes).toBe(0);
|
|
611
|
-
expect(stats.networkTxBytes).toBe(0);
|
|
612
|
-
}, STATS_TIMEOUT_MS);
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
describe('HostProcessOrchestrator log rotation at spawn', () => {
|
|
616
|
-
it('rotates an oversized node.log before the new child opens it', async () => {
|
|
617
|
-
const rootDir = join(tmpRoot, 'rot');
|
|
618
|
-
mkdirSync(rootDir, { recursive: true });
|
|
619
|
-
const containerWorkdir = join(rootDir, 'rotc1');
|
|
620
|
-
mkdirSync(containerWorkdir, { recursive: true });
|
|
621
|
-
// Pre-seed an oversized active log so maybeRotateAtSpawn fires.
|
|
622
|
-
const seed = 'X'.repeat(2048);
|
|
623
|
-
writeFileSync(join(containerWorkdir, 'node.log'), seed, 'utf8');
|
|
624
|
-
|
|
625
|
-
const orch = makeOrchestrator({
|
|
626
|
-
rootDir,
|
|
627
|
-
logMaxBytes: 1024,
|
|
628
|
-
logMaxFiles: 3,
|
|
629
|
-
});
|
|
630
|
-
const { dockerId } = await orch.createContainer(makeRequest('rotc1'));
|
|
631
|
-
await waitFor(() => orch.isRunning(dockerId));
|
|
632
|
-
|
|
633
|
-
// The rotation cascade should have moved the seeded contents to .1
|
|
634
|
-
// and the fresh child should be writing to a brand-new node.log.
|
|
635
|
-
expect(readFileSync(join(containerWorkdir, 'node.log.1'), 'utf8')).toBe(seed);
|
|
636
|
-
expect(statSync(join(containerWorkdir, 'node.log')).size).toBeLessThan(seed.length);
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
describe('HostProcessOrchestrator.getLogs', () => {
|
|
641
|
-
it('returns recent stdout lines from the child', async () => {
|
|
642
|
-
const orch = makeOrchestrator();
|
|
643
|
-
process.env.CHILD_INITIAL_MESSAGE = 'hello-from-fake';
|
|
644
|
-
let dockerId: string;
|
|
645
|
-
try {
|
|
646
|
-
({ dockerId } = await orch.createContainer(makeRequest('c1')));
|
|
647
|
-
} finally {
|
|
648
|
-
delete process.env.CHILD_INITIAL_MESSAGE;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
await waitFor(async () => {
|
|
652
|
-
const logs = await orch.getLogs(dockerId, 200);
|
|
653
|
-
return logs.includes('hello-from-fake');
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
const logs = await orch.getLogs(dockerId, 200);
|
|
657
|
-
expect(logs).toContain('hello-from-fake');
|
|
658
|
-
});
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
describe('rotateOnDisk', () => {
|
|
662
|
-
it('cascades base -> .1 -> ... and caps at maxFiles', async () => {
|
|
663
|
-
const fs = await import('node:fs');
|
|
664
|
-
const dir = join(tmpRoot, 'logs');
|
|
665
|
-
mkdirSync(dir, { recursive: true });
|
|
666
|
-
const path = join(dir, 'node.log');
|
|
667
|
-
|
|
668
|
-
// Seed: base + .1 + .2 + .3 each with distinguishable content.
|
|
669
|
-
writeFileSync(path, 'base', 'utf8');
|
|
670
|
-
writeFileSync(`${path}.1`, 'one', 'utf8');
|
|
671
|
-
writeFileSync(`${path}.2`, 'two', 'utf8');
|
|
672
|
-
writeFileSync(`${path}.3`, 'three', 'utf8');
|
|
673
|
-
|
|
674
|
-
rotateOnDisk(path, 3);
|
|
675
|
-
|
|
676
|
-
// Active path is gone (rotated away). .1 has former base, .2 has former
|
|
677
|
-
// .1, .3 has former .2; former .3 is dropped because maxFiles=3.
|
|
678
|
-
expect(fs.existsSync(path)).toBe(false);
|
|
679
|
-
expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('base');
|
|
680
|
-
expect(fs.readFileSync(`${path}.2`, 'utf8')).toBe('one');
|
|
681
|
-
expect(fs.readFileSync(`${path}.3`, 'utf8')).toBe('two');
|
|
682
|
-
expect(fs.existsSync(`${path}.4`)).toBe(false);
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
it('is a no-op when the active file does not exist', () => {
|
|
686
|
-
const dir = join(tmpRoot, 'logs-empty');
|
|
687
|
-
mkdirSync(dir, { recursive: true });
|
|
688
|
-
const path = join(dir, 'node.log');
|
|
689
|
-
expect(() => rotateOnDisk(path, 3)).not.toThrow();
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
it('handles a partial cascade (only base present)', async () => {
|
|
693
|
-
const fs = await import('node:fs');
|
|
694
|
-
const dir = join(tmpRoot, 'logs-partial');
|
|
695
|
-
mkdirSync(dir, { recursive: true });
|
|
696
|
-
const path = join(dir, 'node.log');
|
|
697
|
-
writeFileSync(path, 'only', 'utf8');
|
|
698
|
-
|
|
699
|
-
rotateOnDisk(path, 5);
|
|
700
|
-
|
|
701
|
-
expect(fs.existsSync(path)).toBe(false);
|
|
702
|
-
expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('only');
|
|
703
|
-
expect(fs.existsSync(`${path}.2`)).toBe(false);
|
|
704
|
-
});
|
|
705
|
-
});
|
|
706
|
-
|
|
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
|
-
describe('Restart recovery (init)', () => {
|
|
767
|
-
it('re-attaches to surviving children and reports dead ones', async () => {
|
|
768
|
-
const rootDir = join(tmpRoot, 'restart');
|
|
769
|
-
const a = makeOrchestrator({ rootDir });
|
|
770
|
-
const r1 = await a.createContainer(makeRequest('c1'));
|
|
771
|
-
const r2 = await a.createContainer(makeRequest('c2'));
|
|
772
|
-
await waitFor(() => a.isRunning(r1.dockerId));
|
|
773
|
-
await waitFor(() => a.isRunning(r2.dockerId));
|
|
774
|
-
|
|
775
|
-
// Kill c2 externally
|
|
776
|
-
const { pid: pid2 } = decodeDockerId(r2.dockerId);
|
|
777
|
-
process.kill(pid2, 'SIGKILL');
|
|
778
|
-
await waitFor(() => !isPidAlive(pid2));
|
|
779
|
-
|
|
780
|
-
// New orchestrator over the same rootDir, init()
|
|
781
|
-
const b = makeOrchestrator({ rootDir });
|
|
782
|
-
await b.init();
|
|
783
|
-
|
|
784
|
-
expect(await b.isRunning(r1.dockerId)).toBe(true);
|
|
785
|
-
expect(await b.isRunning(r2.dockerId)).toBe(false);
|
|
786
|
-
|
|
787
|
-
// Ports from both should be reserved (no overlapping allocations from `b`)
|
|
788
|
-
// Allocate three more containers via `b` and confirm none collide with
|
|
789
|
-
// c1/c2's existing ports.
|
|
790
|
-
const reservedPorts = new Set([
|
|
791
|
-
r1.p2pPort,
|
|
792
|
-
r2.p2pPort,
|
|
793
|
-
Number(r1.healthEndpoint.match(/:(\d+)/)![1]),
|
|
794
|
-
Number(r1.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
795
|
-
Number(r2.healthEndpoint.match(/:(\d+)/)![1]),
|
|
796
|
-
Number(r2.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
797
|
-
]);
|
|
798
|
-
const r3 = await b.createContainer(makeRequest('c3'));
|
|
799
|
-
const r3Ports = new Set([
|
|
800
|
-
r3.p2pPort,
|
|
801
|
-
Number(r3.healthEndpoint.match(/:(\d+)/)![1]),
|
|
802
|
-
Number(r3.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
803
|
-
]);
|
|
804
|
-
for (const p of r3Ports) {
|
|
805
|
-
expect(reservedPorts.has(p)).toBe(false);
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* The orchestrator must spawn children that survive the spawning process
|
|
812
|
-
* itself exiting. The same-process restart test below does not exercise
|
|
813
|
-
* this — both orchestrators live in the same vitest worker, so the
|
|
814
|
-
* spawning process's pipe-read fds never close. This test genuinely exits
|
|
815
|
-
* the spawning process and asserts that the grandchild (a) stays alive
|
|
816
|
-
* past the parent's death and (b) keeps writing to its log file.
|
|
817
|
-
*
|
|
818
|
-
* Requires `yarn workspace @serfab/cadre-host build` to have been run —
|
|
819
|
-
* the helper script imports the built dist so it can run as a plain Node
|
|
820
|
-
* process without a TS loader.
|
|
821
|
-
*/
|
|
822
|
-
describe('child survives orchestrator exit', () => {
|
|
823
|
-
const orchestratorDistUrl = (() => {
|
|
824
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
825
|
-
const distDir = resolvePath(here, '..', '..', 'dist', 'orchestrator');
|
|
826
|
-
const distPath = join(distDir, 'host-process-orchestrator.js');
|
|
827
|
-
return {
|
|
828
|
-
distPath,
|
|
829
|
-
orchestratorUrl: pathToFileURL(distPath).href,
|
|
830
|
-
logRotatorUrl: pathToFileURL(join(distDir, 'log-rotator.js')).href,
|
|
831
|
-
};
|
|
832
|
-
})();
|
|
833
|
-
|
|
834
|
-
it.skipIf(!existsSync(orchestratorDistUrl.distPath))(
|
|
835
|
-
'grandchild stays alive and keeps logging after spawner process exits',
|
|
836
|
-
async () => {
|
|
837
|
-
const { orchestratorUrl, logRotatorUrl } = orchestratorDistUrl;
|
|
838
|
-
const rootDir = join(tmpRoot, 'cross');
|
|
839
|
-
mkdirSync(rootDir, { recursive: true });
|
|
840
|
-
|
|
841
|
-
const helperPath = join(tmpRoot, 'spawner.mjs');
|
|
842
|
-
writeFileSync(helperPath, buildSpawnerScript(orchestratorUrl, logRotatorUrl), 'utf8');
|
|
843
|
-
|
|
844
|
-
const request = {
|
|
845
|
-
containerId: 'cross1',
|
|
846
|
-
partyId: 'party-cross1',
|
|
847
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001'],
|
|
848
|
-
profile: 'transaction',
|
|
849
|
-
};
|
|
850
|
-
const payload = {
|
|
851
|
-
rootDir,
|
|
852
|
-
entrypoint: scriptPath,
|
|
853
|
-
request,
|
|
854
|
-
// Ensure the fake child keeps ticking quickly so logs grow visibly
|
|
855
|
-
// in the ~300 ms observation window.
|
|
856
|
-
childEnv: { CHILD_TICK_MS: '20', CHILD_TICK_PAYLOAD: 'cross-tick' },
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
// Run the helper as a normal (non-detached) Node child and wait for
|
|
860
|
-
// it to exit. The orchestrator's grandchild should survive.
|
|
861
|
-
const result = spawnSync(process.execPath, [helperPath, JSON.stringify(payload)], {
|
|
862
|
-
encoding: 'utf8',
|
|
863
|
-
timeout: 15000,
|
|
864
|
-
});
|
|
865
|
-
expect(result.status, `helper exited non-zero: stderr=${result.stderr}`).toBe(0);
|
|
866
|
-
|
|
867
|
-
const jsonLine = result.stdout
|
|
868
|
-
.split(/\r?\n/)
|
|
869
|
-
.reverse()
|
|
870
|
-
.find((line) => line.trim().startsWith('{'));
|
|
871
|
-
expect(jsonLine, `helper produced no JSON line: stdout=${result.stdout}`).toBeTruthy();
|
|
872
|
-
const handle = JSON.parse(jsonLine!) as {
|
|
873
|
-
dockerId: string;
|
|
874
|
-
pid: number;
|
|
875
|
-
workdir: string;
|
|
876
|
-
logPath: string;
|
|
877
|
-
};
|
|
878
|
-
|
|
879
|
-
try {
|
|
880
|
-
// Immediate liveness
|
|
881
|
-
expect(isPidAlive(handle.pid)).toBe(true);
|
|
882
|
-
|
|
883
|
-
// POSIX delivers SIGPIPE on the child's NEXT write — give it time
|
|
884
|
-
// to attempt one (the fake child ticks every 20 ms).
|
|
885
|
-
await sleep(500);
|
|
886
|
-
expect(isPidAlive(handle.pid), 'grandchild died after spawner exit (likely SIGPIPE)').toBe(true);
|
|
887
|
-
|
|
888
|
-
// Logs must keep growing — proves stdio still works, not just PID.
|
|
889
|
-
const sizeA = statSync(handle.logPath).size;
|
|
890
|
-
await sleep(300);
|
|
891
|
-
const sizeB = statSync(handle.logPath).size;
|
|
892
|
-
expect(sizeB).toBeGreaterThan(sizeA);
|
|
893
|
-
} finally {
|
|
894
|
-
try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
895
|
-
try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
|
|
896
|
-
}
|
|
897
|
-
},
|
|
898
|
-
// The helper spawn alone budgets 15 s; the surrounding waits add ~0.8 s
|
|
899
|
-
// more. Under the 5 s default the test could never reach its own timeout.
|
|
900
|
-
30_000,
|
|
901
|
-
);
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
function buildSpawnerScript(orchestratorUrl: string, logRotatorUrl: string): string {
|
|
905
|
-
return `
|
|
906
|
-
import { HostProcessOrchestrator } from ${JSON.stringify(orchestratorUrl)};
|
|
907
|
-
import { defaultLogPath } from ${JSON.stringify(logRotatorUrl)};
|
|
908
|
-
|
|
909
|
-
const payload = JSON.parse(process.argv[2]);
|
|
910
|
-
const { rootDir, entrypoint, request, childEnv } = payload;
|
|
911
|
-
|
|
912
|
-
for (const [k, v] of Object.entries(childEnv ?? {})) {
|
|
913
|
-
process.env[k] = String(v);
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const orch = new HostProcessOrchestrator({
|
|
917
|
-
rootDir,
|
|
918
|
-
portRange: { start: 19000, end: 19999 },
|
|
919
|
-
stopTimeoutMs: 1500,
|
|
920
|
-
spawn: { entrypoint },
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
const res = await orch.createContainer(request);
|
|
924
|
-
const pid = Number(res.dockerId.split(':')[0]);
|
|
925
|
-
const workdir = (await import('node:path')).join(rootDir, request.containerId);
|
|
926
|
-
const logPath = defaultLogPath(workdir);
|
|
927
|
-
|
|
928
|
-
process.stdout.write(JSON.stringify({
|
|
929
|
-
dockerId: res.dockerId,
|
|
930
|
-
pid,
|
|
931
|
-
workdir,
|
|
932
|
-
logPath,
|
|
933
|
-
}) + '\\n');
|
|
934
|
-
|
|
935
|
-
// Exit cleanly — the detached/unref'd child should outlive us.
|
|
936
|
-
process.exit(0);
|
|
937
|
-
`;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
describe('dockerId encoding', () => {
|
|
941
|
-
it('encode/decode round-trips pid and token', () => {
|
|
942
|
-
const id = encodeDockerId(12345, 'abcdef');
|
|
943
|
-
expect(decodeDockerId(id)).toEqual({ pid: 12345, token: 'abcdef' });
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
it('rejects malformed ids', () => {
|
|
947
|
-
expect(() => decodeDockerId('notvalid')).toThrow();
|
|
948
|
-
expect(() => decodeDockerId(':oops')).toThrow();
|
|
949
|
-
expect(() => decodeDockerId('0:tok')).toThrow();
|
|
950
|
-
});
|
|
951
|
-
});
|
|
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 { allocateNodePorts, 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
|
+
import { loadIdentity } from '../installer/identity.js';
|
|
23
|
+
|
|
24
|
+
const FAKE_CHILD = `
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const configIdx = args.indexOf('-c');
|
|
30
|
+
const tokenIdx = args.indexOf('--startup-token-file');
|
|
31
|
+
const tokenPath = tokenIdx >= 0 ? args[tokenIdx + 1] : null;
|
|
32
|
+
const ignoreSigterm = process.env.IGNORE_SIGTERM === '1';
|
|
33
|
+
const initialMessage = process.env.CHILD_INITIAL_MESSAGE ?? 'tick';
|
|
34
|
+
const tickIntervalMs = parseInt(process.env.CHILD_TICK_MS ?? '50', 10);
|
|
35
|
+
const tickPayload = process.env.CHILD_TICK_PAYLOAD ?? 'tick';
|
|
36
|
+
|
|
37
|
+
if (tokenPath && process.env.CADRE_STARTUP_TOKEN) {
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(tokenPath, process.env.CADRE_STARTUP_TOKEN, 'utf8');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('failed to write token:', err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let alive = true;
|
|
46
|
+
process.on('SIGTERM', () => {
|
|
47
|
+
if (ignoreSigterm) {
|
|
48
|
+
console.log('ignoring SIGTERM');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
alive = false;
|
|
52
|
+
console.log('exiting on SIGTERM');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(initialMessage);
|
|
57
|
+
|
|
58
|
+
const interval = setInterval(() => {
|
|
59
|
+
if (alive) {
|
|
60
|
+
process.stdout.write(tickPayload + '\\n');
|
|
61
|
+
} else {
|
|
62
|
+
clearInterval(interval);
|
|
63
|
+
}
|
|
64
|
+
}, tickIntervalMs);
|
|
65
|
+
|
|
66
|
+
setInterval(() => {}, 1 << 30);
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
let tmpRoot: string;
|
|
70
|
+
let scriptPath: string;
|
|
71
|
+
const orchestrators: HostProcessOrchestrator[] = [];
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-orch-'));
|
|
75
|
+
scriptPath = join(tmpRoot, 'fake-child.mjs');
|
|
76
|
+
writeFileSync(scriptPath, FAKE_CHILD, 'utf8');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
// Force-kill any leftover children before cleanup.
|
|
81
|
+
for (const orch of orchestrators) {
|
|
82
|
+
for (const dockerId of listDockerIds(orch)) {
|
|
83
|
+
try { await orch.removeContainer(dockerId); } catch { /* ignore */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
orchestrators.length = 0;
|
|
87
|
+
await sleep(50);
|
|
88
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function makeOrchestrator(overrides: Partial<ConstructorParameters<typeof HostProcessOrchestrator>[0]> = {}): HostProcessOrchestrator {
|
|
92
|
+
const rootDir = overrides.rootDir ?? join(tmpRoot, 'orch');
|
|
93
|
+
mkdirSync(rootDir, { recursive: true });
|
|
94
|
+
const orch = new HostProcessOrchestrator({
|
|
95
|
+
rootDir,
|
|
96
|
+
portRange: { start: 18000, end: 18999 },
|
|
97
|
+
stopTimeoutMs: 1500,
|
|
98
|
+
logMaxBytes: overrides.logMaxBytes ?? 10 * 1024 * 1024,
|
|
99
|
+
logMaxFiles: overrides.logMaxFiles ?? 5,
|
|
100
|
+
spawn: { entrypoint: overrides.spawn?.entrypoint ?? scriptPath },
|
|
101
|
+
...overrides,
|
|
102
|
+
});
|
|
103
|
+
orchestrators.push(orch);
|
|
104
|
+
return orch;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listDockerIds(orch: HostProcessOrchestrator): string[] {
|
|
108
|
+
// Use the state file as the source of truth since handles map is private.
|
|
109
|
+
const state = new StateStore((orch as unknown as { rootDir: string }).rootDir);
|
|
110
|
+
return state.load().handles.map((h) => h.dockerId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 3000, intervalMs = 50): Promise<void> {
|
|
114
|
+
const deadline = Date.now() + timeoutMs;
|
|
115
|
+
while (Date.now() < deadline) {
|
|
116
|
+
if (await predicate()) return;
|
|
117
|
+
await sleep(intervalMs);
|
|
118
|
+
}
|
|
119
|
+
throw new Error('waitFor: timed out');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sleep(ms: number): Promise<void> {
|
|
123
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function makeRequest(containerId: string, opts: { profile?: 'storage' | 'transaction' } = {}) {
|
|
127
|
+
return {
|
|
128
|
+
containerId,
|
|
129
|
+
partyId: 'party-' + containerId,
|
|
130
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
131
|
+
profile: opts.profile ?? 'transaction' as 'storage' | 'transaction',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('HostProcessOrchestrator.createContainer', () => {
|
|
136
|
+
it('spawns a live child and persists handle, ports, and endpoints', async () => {
|
|
137
|
+
const orch = makeOrchestrator();
|
|
138
|
+
const result = await orch.createContainer(makeRequest('c1'));
|
|
139
|
+
|
|
140
|
+
// Endpoints derive from allocated ports
|
|
141
|
+
expect(result.healthEndpoint).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
142
|
+
expect(result.metricsEndpoint).toMatch(/^http:\/\/localhost:\d+\/metrics$/);
|
|
143
|
+
expect(result.p2pPort).toBeGreaterThanOrEqual(18000);
|
|
144
|
+
|
|
145
|
+
const { pid } = decodeDockerId(result.dockerId);
|
|
146
|
+
await waitFor(() => isPidAlive(pid));
|
|
147
|
+
expect(isPidAlive(pid)).toBe(true);
|
|
148
|
+
|
|
149
|
+
// Wait until the child has written its startup token file
|
|
150
|
+
await waitFor(() => orch.isRunning(result.dockerId));
|
|
151
|
+
expect(await orch.isRunning(result.dockerId)).toBe(true);
|
|
152
|
+
|
|
153
|
+
// Workdir scaffolded
|
|
154
|
+
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
155
|
+
expect(readFileSync(join(workdir, 'cadre.json'), 'utf8')).toContain('party-c1');
|
|
156
|
+
|
|
157
|
+
// State persisted
|
|
158
|
+
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
159
|
+
expect(persisted.handles).toHaveLength(1);
|
|
160
|
+
expect(persisted.handles[0]!.dockerId).toBe(result.dockerId);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('HostProcessOrchestrator re-spawn of the same containerId', () => {
|
|
165
|
+
// The donated-node respawn path calls createContainer again with the id of a
|
|
166
|
+
// container the host still holds a (now dead) handle for. Handles are keyed by
|
|
167
|
+
// the per-spawn dockerId, so without an explicit drop the old one would linger
|
|
168
|
+
// forever and its four ports would never come back.
|
|
169
|
+
it('drops the stale handle, releases its ports, and keeps the identity', async () => {
|
|
170
|
+
const orch = makeOrchestrator();
|
|
171
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
172
|
+
|
|
173
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
174
|
+
await waitFor(() => orch.isRunning(first.dockerId));
|
|
175
|
+
const identityPath = join(rootDir, 'c1', 'identity.key');
|
|
176
|
+
const peerBefore = loadIdentity(identityPath).peerId;
|
|
177
|
+
|
|
178
|
+
// Kill the child the way a crash would — the handle survives, still marked
|
|
179
|
+
// alive until something notices.
|
|
180
|
+
const { pid: firstPid } = decodeDockerId(first.dockerId);
|
|
181
|
+
process.kill(firstPid, 'SIGKILL');
|
|
182
|
+
await waitFor(() => !isPidAlive(firstPid));
|
|
183
|
+
|
|
184
|
+
const second = await orch.createContainer(makeRequest('c1'));
|
|
185
|
+
|
|
186
|
+
// Exactly one handle for c1, and it is the new spawn.
|
|
187
|
+
const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
|
|
188
|
+
expect(handles).toHaveLength(1);
|
|
189
|
+
expect(handles[0]!.dockerId).toBe(second.dockerId);
|
|
190
|
+
|
|
191
|
+
// The allocator hands back the lowest free port, so re-using the first
|
|
192
|
+
// spawn's exact ports is what proves they were released.
|
|
193
|
+
expect(second.p2pPort).toBe(first.p2pPort);
|
|
194
|
+
expect(second.healthEndpoint).toBe(first.healthEndpoint);
|
|
195
|
+
|
|
196
|
+
// Same workdir, same key → same peer id. This is what makes a respawn the
|
|
197
|
+
// same node rather than a new one the borrower's cadre has never approved.
|
|
198
|
+
expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A launch that fails must leave host state exactly as it found it. The drop of
|
|
204
|
+
* the previous handle happens before the child is launched, so without an
|
|
205
|
+
* explicit restore a failed re-spawn would leave the donation record naming a
|
|
206
|
+
* dockerId the orchestrator can no longer resolve — and the node's workdir
|
|
207
|
+
* (identity key, node-local stores, storage) stranded on disk with nothing able
|
|
208
|
+
* to delete it.
|
|
209
|
+
*
|
|
210
|
+
* Inducing a launch failure portably: replace the workdir's `storage`
|
|
211
|
+
* directory with a plain file. `mkdirSync(path, { recursive: true })` throws
|
|
212
|
+
* EEXIST when the path exists as a non-directory on every platform, and it is
|
|
213
|
+
* the second statement in `launchChild` — after the handle drop, before the
|
|
214
|
+
* spawn.
|
|
215
|
+
*/
|
|
216
|
+
describe('HostProcessOrchestrator failed launch', () => {
|
|
217
|
+
/** Replace `<workdir>/storage` with a file so the next launch throws EEXIST. */
|
|
218
|
+
function sabotageWorkdir(workdir: string): string {
|
|
219
|
+
const storage = join(workdir, 'storage');
|
|
220
|
+
rmSync(storage, { recursive: true, force: true });
|
|
221
|
+
writeFileSync(storage, 'not-a-directory', 'utf8');
|
|
222
|
+
return storage;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it('keeps the prior handle addressable, so a later terminate still reclaims the workdir', async () => {
|
|
226
|
+
const orch = makeOrchestrator();
|
|
227
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
228
|
+
|
|
229
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
230
|
+
await waitFor(() => orch.isRunning(first.dockerId));
|
|
231
|
+
await orch.stopContainer(first.dockerId);
|
|
232
|
+
|
|
233
|
+
const workdir = join(rootDir, 'c1');
|
|
234
|
+
sabotageWorkdir(workdir);
|
|
235
|
+
|
|
236
|
+
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow(/EEXIST|exists/i);
|
|
237
|
+
|
|
238
|
+
// The record's dockerId still resolves, and no ghost handle was added.
|
|
239
|
+
expect(orch.getNode('c1')?.dockerId).toBe(first.dockerId);
|
|
240
|
+
expect(orch.resolveDockerId('c1')).toBe(first.dockerId);
|
|
241
|
+
expect(orch.listNodes()).toHaveLength(1);
|
|
242
|
+
|
|
243
|
+
// Which is the whole point: cleanup can still find and delete the node.
|
|
244
|
+
await orch.removeContainer(first.dockerId);
|
|
245
|
+
expect(existsSync(workdir)).toBe(false);
|
|
246
|
+
expect(orch.listNodes()).toHaveLength(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does not shift the ports a successful retry comes back on', async () => {
|
|
250
|
+
const orch = makeOrchestrator();
|
|
251
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
252
|
+
|
|
253
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
254
|
+
await waitFor(() => orch.isRunning(first.dockerId));
|
|
255
|
+
await orch.stopContainer(first.dockerId);
|
|
256
|
+
|
|
257
|
+
const workdir = join(rootDir, 'c1');
|
|
258
|
+
const storage = sabotageWorkdir(workdir);
|
|
259
|
+
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
|
|
260
|
+
|
|
261
|
+
rmSync(storage, { force: true });
|
|
262
|
+
const second = await orch.createContainer(makeRequest('c1'));
|
|
263
|
+
|
|
264
|
+
// The restored handle held its four ports across the failure, and the
|
|
265
|
+
// allocator hands back the lowest free port — so identical ports prove the
|
|
266
|
+
// failed attempt neither leaked nor stole them.
|
|
267
|
+
expect(second.p2pPort).toBe(first.p2pPort);
|
|
268
|
+
expect(second.healthEndpoint).toBe(first.healthEndpoint);
|
|
269
|
+
expect(second.metricsEndpoint).toBe(first.metricsEndpoint);
|
|
270
|
+
|
|
271
|
+
// And the successful spawn's own drop still stands: exactly one handle.
|
|
272
|
+
const handles = new StateStore(rootDir).load().handles.filter((h) => h.containerId === 'c1');
|
|
273
|
+
expect(handles).toHaveLength(1);
|
|
274
|
+
expect(handles[0]!.dockerId).toBe(second.dockerId);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('releases every port a failed FIRST spawn reserved', async () => {
|
|
278
|
+
const orch = makeOrchestrator({ portRange: { start: 12000, end: 12100 } });
|
|
279
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
280
|
+
|
|
281
|
+
await orch.createContainer(makeRequest('c1'));
|
|
282
|
+
expect(orch.getNode('c1')!.ports).toEqual({ health: 12000, metrics: 12001, p2p: 12002, admin: 12003 });
|
|
283
|
+
|
|
284
|
+
// No prior handle to restore here — the four ports are simply released.
|
|
285
|
+
mkdirSync(join(rootDir, 'c2'), { recursive: true });
|
|
286
|
+
writeFileSync(join(rootDir, 'c2', 'storage'), 'not-a-directory', 'utf8');
|
|
287
|
+
await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow();
|
|
288
|
+
|
|
289
|
+
await orch.createContainer(makeRequest('c3'));
|
|
290
|
+
// With the leak these would start at 12008.
|
|
291
|
+
expect(orch.getNode('c3')!.ports).toEqual({ health: 12004, metrics: 12005, p2p: 12006, admin: 12007 });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('leaves the owner node addressable on its original ports', async () => {
|
|
295
|
+
const orch = makeOrchestrator();
|
|
296
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
297
|
+
const ownerConfig = {
|
|
298
|
+
identityPath: join(tmpRoot, 'identity.key'),
|
|
299
|
+
partyId: 'party-owner',
|
|
300
|
+
libp2pPort: 14001,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const owner = await orch.ensureOwnerNode(ownerConfig);
|
|
304
|
+
await waitFor(() => orch.isRunning(owner.dockerId));
|
|
305
|
+
const endpointBefore = orch.getOwnerAdminEndpoint();
|
|
306
|
+
|
|
307
|
+
// ensureOwnerNode short-circuits on a live child, so kill it first.
|
|
308
|
+
const { pid } = decodeDockerId(owner.dockerId);
|
|
309
|
+
process.kill(pid, 'SIGKILL');
|
|
310
|
+
await waitFor(() => !isPidAlive(pid));
|
|
311
|
+
|
|
312
|
+
const storage = sabotageWorkdir(join(rootDir, 'owner'));
|
|
313
|
+
await expect(orch.ensureOwnerNode()).rejects.toThrow();
|
|
314
|
+
|
|
315
|
+
// The admin channel still resolves — a manager that lost this endpoint
|
|
316
|
+
// could no longer delegate to its own node.
|
|
317
|
+
expect(orch.getOwnerAdminEndpoint()).toEqual(endpointBefore);
|
|
318
|
+
expect(orch.listNodes().some((n) => n.dockerId === owner.dockerId)).toBe(true);
|
|
319
|
+
|
|
320
|
+
rmSync(storage, { force: true });
|
|
321
|
+
const respawned = await orch.ensureOwnerNode();
|
|
322
|
+
expect(respawned.ports).toEqual(owner.ports);
|
|
323
|
+
expect(orch.listNodes().filter((n) => n.owner)).toHaveLength(1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// The caller's unwind is only correct for failures that happen BEFORE the
|
|
327
|
+
// child exists. A state.json write failure happens after it — if that were
|
|
328
|
+
// allowed to propagate, the unwind would release the ports the live child is
|
|
329
|
+
// bound to and put the replaced handle back beside it.
|
|
330
|
+
it('does not unwind a launch that already spawned when the state write fails', async () => {
|
|
331
|
+
const orch = makeOrchestrator();
|
|
332
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
333
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
334
|
+
await waitFor(() => orch.isRunning(first.dockerId));
|
|
335
|
+
await orch.stopContainer(first.dockerId);
|
|
336
|
+
|
|
337
|
+
const stateStore = (orch as unknown as { stateStore: StateStore }).stateStore;
|
|
338
|
+
const save = stateStore.save.bind(stateStore);
|
|
339
|
+
stateStore.save = () => { throw new Error('ENOSPC: no space left on device'); };
|
|
340
|
+
const second = await orch.createContainer(makeRequest('c1'));
|
|
341
|
+
stateStore.save = save;
|
|
342
|
+
|
|
343
|
+
// One handle for the container, and it is the new one.
|
|
344
|
+
expect(orch.listNodes()).toHaveLength(1);
|
|
345
|
+
expect(orch.getNode('c1')?.dockerId).toBe(second.dockerId);
|
|
346
|
+
|
|
347
|
+
// And the workdir the live child is running in is untouched — a swallowed
|
|
348
|
+
// persist must not reach the unwind that would delete it.
|
|
349
|
+
expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
|
|
350
|
+
|
|
351
|
+
// Its ports are still held: a fresh container gets four different ones.
|
|
352
|
+
const other = await orch.createContainer(makeRequest('c2'));
|
|
353
|
+
expect(other.p2pPort).not.toBe(second.p2pPort);
|
|
354
|
+
expect(orch.getNode('c2')!.ports).not.toEqual(orch.getNode('c1')!.ports);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// A spawn that CREATED the workdir must take it back down on the way out —
|
|
358
|
+
// the record it was provisioning never got a dockerId, so nothing else can
|
|
359
|
+
// ever name that directory. The storage-file sabotage above is unusable here:
|
|
360
|
+
// pre-creating `<workdir>/storage` makes the directory pre-existing, and the
|
|
361
|
+
// unwind then correctly declines to delete it. Exhausting the port range
|
|
362
|
+
// fails the very next step instead, with the workdir genuinely fresh.
|
|
363
|
+
it('discards the workdir a failed first spawn created', async () => {
|
|
364
|
+
// Exactly four ports — enough for one node and no more.
|
|
365
|
+
const orch = makeOrchestrator({ portRange: { start: 13000, end: 13003 } });
|
|
366
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
367
|
+
|
|
368
|
+
await orch.createContainer(makeRequest('c1'));
|
|
369
|
+
await expect(orch.createContainer(makeRequest('c2'))).rejects.toThrow(/No available ports/);
|
|
370
|
+
|
|
371
|
+
// `ensureNodeIdentity` had already created it and written the key.
|
|
372
|
+
expect(existsSync(join(rootDir, 'c2'))).toBe(false);
|
|
373
|
+
// The first node is untouched.
|
|
374
|
+
expect(existsSync(join(rootDir, 'c1', 'identity.key'))).toBe(true);
|
|
375
|
+
expect(orch.listNodes()).toHaveLength(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// The load-bearing case in the other direction: a re-spawn is REUSING the
|
|
379
|
+
// directory, and the identity key inside it is the whole reason the node
|
|
380
|
+
// comes back as the same peer the borrower's cadre approved.
|
|
381
|
+
it('keeps the workdir when a re-spawn launch fails', async () => {
|
|
382
|
+
const orch = makeOrchestrator();
|
|
383
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
384
|
+
|
|
385
|
+
const first = await orch.createContainer(makeRequest('c1'));
|
|
386
|
+
await waitFor(() => orch.isRunning(first.dockerId));
|
|
387
|
+
await orch.stopContainer(first.dockerId);
|
|
388
|
+
|
|
389
|
+
const workdir = join(rootDir, 'c1');
|
|
390
|
+
const identityPath = join(workdir, 'identity.key');
|
|
391
|
+
const peerBefore = loadIdentity(identityPath).peerId;
|
|
392
|
+
|
|
393
|
+
const storage = sabotageWorkdir(workdir);
|
|
394
|
+
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
|
|
395
|
+
|
|
396
|
+
expect(existsSync(workdir)).toBe(true);
|
|
397
|
+
expect(existsSync(identityPath)).toBe(true);
|
|
398
|
+
|
|
399
|
+
// Which is what makes the next attempt the SAME node, not a stranger.
|
|
400
|
+
rmSync(storage, { force: true });
|
|
401
|
+
await orch.createContainer(makeRequest('c1'));
|
|
402
|
+
expect(loadIdentity(identityPath).peerId).toBe(peerBefore);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// `ensureNodeIdentity` now runs inside the same `try`, so its own failures
|
|
406
|
+
// unwind too — and this one must NOT delete anything. The directory was
|
|
407
|
+
// pre-existing, so the undecodable key is an operator-visible fault to
|
|
408
|
+
// investigate, not this attempt's mess to clear up.
|
|
409
|
+
it('keeps a pre-existing workdir whose identity key is undecodable', async () => {
|
|
410
|
+
const orch = makeOrchestrator();
|
|
411
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
412
|
+
const workdir = join(rootDir, 'c1');
|
|
413
|
+
|
|
414
|
+
mkdirSync(workdir, { recursive: true });
|
|
415
|
+
writeFileSync(join(workdir, 'identity.key'), Buffer.from([0xff, 0xff, 0xff, 0xff]));
|
|
416
|
+
|
|
417
|
+
await expect(orch.createContainer(makeRequest('c1'))).rejects.toThrow();
|
|
418
|
+
|
|
419
|
+
expect(existsSync(workdir)).toBe(true);
|
|
420
|
+
expect(existsSync(join(workdir, 'identity.key'))).toBe(true);
|
|
421
|
+
expect(orch.listNodes()).toHaveLength(0);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The cleanup of last resort: a donation record that never got a `dockerId`
|
|
427
|
+
* because the host died mid-spawn. `createContainer`'s own unwind cannot help —
|
|
428
|
+
* it was never reached — so the reap falls back to reclaiming by container name.
|
|
429
|
+
*/
|
|
430
|
+
describe('HostProcessOrchestrator.reclaimWorkdir', () => {
|
|
431
|
+
it('removes an orphaned workdir no handle owns', async () => {
|
|
432
|
+
const orch = makeOrchestrator();
|
|
433
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
434
|
+
const workdir = join(rootDir, 'grn_orphan');
|
|
435
|
+
mkdirSync(workdir, { recursive: true });
|
|
436
|
+
writeFileSync(join(workdir, 'identity.key'), 'key-bytes', 'utf8');
|
|
437
|
+
|
|
438
|
+
expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
|
|
439
|
+
expect(existsSync(workdir)).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('refuses a container a live handle still owns', async () => {
|
|
443
|
+
const orch = makeOrchestrator();
|
|
444
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
445
|
+
const created = await orch.createContainer(makeRequest('c1'));
|
|
446
|
+
|
|
447
|
+
// That directory is the live child's cwd; only `removeContainer`, which
|
|
448
|
+
// stops the child first, may delete it.
|
|
449
|
+
expect(orch.reclaimWorkdir('c1')).toBe(false);
|
|
450
|
+
expect(existsSync(join(rootDir, 'c1'))).toBe(true);
|
|
451
|
+
expect(orch.resolveDockerId('c1')).toBe(created.dockerId);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('refuses the owner node', async () => {
|
|
455
|
+
const orch = makeOrchestrator();
|
|
456
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
457
|
+
// No owner handle exists, so only the id check can save this directory —
|
|
458
|
+
// which holds the host's own control-DB storage.
|
|
459
|
+
const workdir = join(rootDir, 'owner');
|
|
460
|
+
mkdirSync(workdir, { recursive: true });
|
|
461
|
+
|
|
462
|
+
expect(orch.reclaimWorkdir('owner')).toBe(false);
|
|
463
|
+
expect(existsSync(workdir)).toBe(true);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('reports false for a workdir that is already gone', () => {
|
|
467
|
+
const orch = makeOrchestrator();
|
|
468
|
+
expect(orch.reclaimWorkdir('grn_never_existed')).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('refuses an id that names a file rather than a workdir', () => {
|
|
472
|
+
const orch = makeOrchestrator();
|
|
473
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
474
|
+
// `rootDir` is not only container directories — `state.json` is the
|
|
475
|
+
// orchestrator's own persisted handle map, and `rmSync` would take it.
|
|
476
|
+
const statePath = join(rootDir, 'state.json');
|
|
477
|
+
writeFileSync(statePath, '{"handles":[]}', 'utf8');
|
|
478
|
+
|
|
479
|
+
expect(orch.reclaimWorkdir('state.json')).toBe(false);
|
|
480
|
+
expect(existsSync(statePath)).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('refuses an id that would escape rootDir', () => {
|
|
484
|
+
const orch = makeOrchestrator();
|
|
485
|
+
const rootDir = (orch as unknown as { rootDir: string }).rootDir;
|
|
486
|
+
const sibling = join(rootDir, '..', 'sibling');
|
|
487
|
+
mkdirSync(sibling, { recursive: true });
|
|
488
|
+
|
|
489
|
+
// Unreachable with real `grn_<base64url>` ids; the guard is defence for a
|
|
490
|
+
// future caller that passes something else.
|
|
491
|
+
expect(orch.reclaimWorkdir('../sibling')).toBe(false);
|
|
492
|
+
expect(existsSync(sibling)).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('HostProcessOrchestrator.isRunning', () => {
|
|
497
|
+
it('is true after spawn, false after kill, false on tampered token', async () => {
|
|
498
|
+
const orch = makeOrchestrator();
|
|
499
|
+
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
500
|
+
const { pid } = decodeDockerId(dockerId);
|
|
501
|
+
|
|
502
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
503
|
+
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
504
|
+
|
|
505
|
+
// Tamper with the token file
|
|
506
|
+
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
507
|
+
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
|
+
expect(await orch.isRunning(dockerId)).toBe(true);
|
|
514
|
+
|
|
515
|
+
process.kill(pid, 'SIGKILL');
|
|
516
|
+
await waitFor(() => !isPidAlive(pid));
|
|
517
|
+
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe('HostProcessOrchestrator.stopContainer', () => {
|
|
522
|
+
it('SIGTERM stops a cooperative child without needing SIGKILL', async () => {
|
|
523
|
+
const orch = makeOrchestrator();
|
|
524
|
+
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
525
|
+
const { pid } = decodeDockerId(dockerId);
|
|
526
|
+
|
|
527
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
528
|
+
await orch.stopContainer(dockerId);
|
|
529
|
+
|
|
530
|
+
expect(isPidAlive(pid)).toBe(false);
|
|
531
|
+
expect(await orch.isRunning(dockerId)).toBe(false);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// On Windows, process.kill ignores the signal name and always force-kills,
|
|
535
|
+
// so a child cannot ignore SIGTERM at the Node level. Skip there.
|
|
536
|
+
it.skipIf(process.platform === 'win32')(
|
|
537
|
+
'escalates to SIGKILL when the child ignores SIGTERM',
|
|
538
|
+
async () => {
|
|
539
|
+
const orch = makeOrchestrator();
|
|
540
|
+
process.env.IGNORE_SIGTERM = '1';
|
|
541
|
+
let dockerId: string;
|
|
542
|
+
try {
|
|
543
|
+
({ dockerId } = await orch.createContainer(makeRequest('c2')));
|
|
544
|
+
} finally {
|
|
545
|
+
delete process.env.IGNORE_SIGTERM;
|
|
546
|
+
}
|
|
547
|
+
const { pid } = decodeDockerId(dockerId);
|
|
548
|
+
await waitFor(() => isPidAlive(pid));
|
|
549
|
+
|
|
550
|
+
const start = Date.now();
|
|
551
|
+
await orch.stopContainer(dockerId);
|
|
552
|
+
const elapsed = Date.now() - start;
|
|
553
|
+
|
|
554
|
+
// Should have waited the full stopTimeoutMs (1500) before SIGKILL.
|
|
555
|
+
expect(elapsed).toBeGreaterThanOrEqual(1000);
|
|
556
|
+
expect(isPidAlive(pid)).toBe(false);
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe('HostProcessOrchestrator.removeContainer', () => {
|
|
562
|
+
it('releases ports, deletes workdir, drops state', async () => {
|
|
563
|
+
const orch = makeOrchestrator();
|
|
564
|
+
const { dockerId, healthEndpoint } = await orch.createContainer(makeRequest('c1'));
|
|
565
|
+
const port = Number(healthEndpoint.match(/:(\d+)/)![1]);
|
|
566
|
+
|
|
567
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
568
|
+
await orch.removeContainer(dockerId);
|
|
569
|
+
|
|
570
|
+
// Workdir is gone
|
|
571
|
+
const workdir = join((orch as unknown as { rootDir: string }).rootDir, 'c1');
|
|
572
|
+
expect(() => readFileSync(join(workdir, 'cadre.json'), 'utf8')).toThrow();
|
|
573
|
+
|
|
574
|
+
// State no longer references it
|
|
575
|
+
const persisted = new StateStore((orch as unknown as { rootDir: string }).rootDir).load();
|
|
576
|
+
expect(persisted.handles).toHaveLength(0);
|
|
577
|
+
|
|
578
|
+
// Subsequent getStats rejects
|
|
579
|
+
await expect(orch.getStats(dockerId)).rejects.toThrow(/not found/i);
|
|
580
|
+
|
|
581
|
+
// Port was released — reallocate it via a fresh container; pick the next id.
|
|
582
|
+
const next = await orch.createContainer(makeRequest('c2'));
|
|
583
|
+
// (we don't assert the same port — just that allocation works post-release)
|
|
584
|
+
expect(next.healthEndpoint).toMatch(/:\d+\/health/);
|
|
585
|
+
expect(port).toBeGreaterThan(0);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* `getStats` goes through `pidusage`, which on Windows shells out: modern
|
|
591
|
+
* builds no longer ship `wmic`, so it falls back to spawning `powershell.exe`
|
|
592
|
+
* to run `gwmi win32_process` — and the first sample for a fresh pid costs
|
|
593
|
+
* *two* such spawns, because CPU percent needs a prior baseline. Measured
|
|
594
|
+
* ~2.8 s idle and ~4.9 s on a loaded machine, so the 5 s default test timeout
|
|
595
|
+
* is not a real budget for this one. Give it explicit headroom.
|
|
596
|
+
*/
|
|
597
|
+
const STATS_TIMEOUT_MS = 30_000;
|
|
598
|
+
|
|
599
|
+
describe('HostProcessOrchestrator.getStats', () => {
|
|
600
|
+
it('returns plausible numbers with zero network counters', async () => {
|
|
601
|
+
const orch = makeOrchestrator();
|
|
602
|
+
const { dockerId } = await orch.createContainer(makeRequest('c1'));
|
|
603
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
604
|
+
// Give the child a moment to use some CPU/mem
|
|
605
|
+
await sleep(200);
|
|
606
|
+
|
|
607
|
+
const stats = await orch.getStats(dockerId);
|
|
608
|
+
expect(stats.cpuPercent).toBeGreaterThanOrEqual(0);
|
|
609
|
+
expect(stats.memoryBytes).toBeGreaterThan(0);
|
|
610
|
+
expect(stats.networkRxBytes).toBe(0);
|
|
611
|
+
expect(stats.networkTxBytes).toBe(0);
|
|
612
|
+
}, STATS_TIMEOUT_MS);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe('HostProcessOrchestrator log rotation at spawn', () => {
|
|
616
|
+
it('rotates an oversized node.log before the new child opens it', async () => {
|
|
617
|
+
const rootDir = join(tmpRoot, 'rot');
|
|
618
|
+
mkdirSync(rootDir, { recursive: true });
|
|
619
|
+
const containerWorkdir = join(rootDir, 'rotc1');
|
|
620
|
+
mkdirSync(containerWorkdir, { recursive: true });
|
|
621
|
+
// Pre-seed an oversized active log so maybeRotateAtSpawn fires.
|
|
622
|
+
const seed = 'X'.repeat(2048);
|
|
623
|
+
writeFileSync(join(containerWorkdir, 'node.log'), seed, 'utf8');
|
|
624
|
+
|
|
625
|
+
const orch = makeOrchestrator({
|
|
626
|
+
rootDir,
|
|
627
|
+
logMaxBytes: 1024,
|
|
628
|
+
logMaxFiles: 3,
|
|
629
|
+
});
|
|
630
|
+
const { dockerId } = await orch.createContainer(makeRequest('rotc1'));
|
|
631
|
+
await waitFor(() => orch.isRunning(dockerId));
|
|
632
|
+
|
|
633
|
+
// The rotation cascade should have moved the seeded contents to .1
|
|
634
|
+
// and the fresh child should be writing to a brand-new node.log.
|
|
635
|
+
expect(readFileSync(join(containerWorkdir, 'node.log.1'), 'utf8')).toBe(seed);
|
|
636
|
+
expect(statSync(join(containerWorkdir, 'node.log')).size).toBeLessThan(seed.length);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe('HostProcessOrchestrator.getLogs', () => {
|
|
641
|
+
it('returns recent stdout lines from the child', async () => {
|
|
642
|
+
const orch = makeOrchestrator();
|
|
643
|
+
process.env.CHILD_INITIAL_MESSAGE = 'hello-from-fake';
|
|
644
|
+
let dockerId: string;
|
|
645
|
+
try {
|
|
646
|
+
({ dockerId } = await orch.createContainer(makeRequest('c1')));
|
|
647
|
+
} finally {
|
|
648
|
+
delete process.env.CHILD_INITIAL_MESSAGE;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
await waitFor(async () => {
|
|
652
|
+
const logs = await orch.getLogs(dockerId, 200);
|
|
653
|
+
return logs.includes('hello-from-fake');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const logs = await orch.getLogs(dockerId, 200);
|
|
657
|
+
expect(logs).toContain('hello-from-fake');
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('rotateOnDisk', () => {
|
|
662
|
+
it('cascades base -> .1 -> ... and caps at maxFiles', async () => {
|
|
663
|
+
const fs = await import('node:fs');
|
|
664
|
+
const dir = join(tmpRoot, 'logs');
|
|
665
|
+
mkdirSync(dir, { recursive: true });
|
|
666
|
+
const path = join(dir, 'node.log');
|
|
667
|
+
|
|
668
|
+
// Seed: base + .1 + .2 + .3 each with distinguishable content.
|
|
669
|
+
writeFileSync(path, 'base', 'utf8');
|
|
670
|
+
writeFileSync(`${path}.1`, 'one', 'utf8');
|
|
671
|
+
writeFileSync(`${path}.2`, 'two', 'utf8');
|
|
672
|
+
writeFileSync(`${path}.3`, 'three', 'utf8');
|
|
673
|
+
|
|
674
|
+
rotateOnDisk(path, 3);
|
|
675
|
+
|
|
676
|
+
// Active path is gone (rotated away). .1 has former base, .2 has former
|
|
677
|
+
// .1, .3 has former .2; former .3 is dropped because maxFiles=3.
|
|
678
|
+
expect(fs.existsSync(path)).toBe(false);
|
|
679
|
+
expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('base');
|
|
680
|
+
expect(fs.readFileSync(`${path}.2`, 'utf8')).toBe('one');
|
|
681
|
+
expect(fs.readFileSync(`${path}.3`, 'utf8')).toBe('two');
|
|
682
|
+
expect(fs.existsSync(`${path}.4`)).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('is a no-op when the active file does not exist', () => {
|
|
686
|
+
const dir = join(tmpRoot, 'logs-empty');
|
|
687
|
+
mkdirSync(dir, { recursive: true });
|
|
688
|
+
const path = join(dir, 'node.log');
|
|
689
|
+
expect(() => rotateOnDisk(path, 3)).not.toThrow();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('handles a partial cascade (only base present)', async () => {
|
|
693
|
+
const fs = await import('node:fs');
|
|
694
|
+
const dir = join(tmpRoot, 'logs-partial');
|
|
695
|
+
mkdirSync(dir, { recursive: true });
|
|
696
|
+
const path = join(dir, 'node.log');
|
|
697
|
+
writeFileSync(path, 'only', 'utf8');
|
|
698
|
+
|
|
699
|
+
rotateOnDisk(path, 5);
|
|
700
|
+
|
|
701
|
+
expect(fs.existsSync(path)).toBe(false);
|
|
702
|
+
expect(fs.readFileSync(`${path}.1`, 'utf8')).toBe('only');
|
|
703
|
+
expect(fs.existsSync(`${path}.2`)).toBe(false);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
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
|
+
describe('Restart recovery (init)', () => {
|
|
767
|
+
it('re-attaches to surviving children and reports dead ones', async () => {
|
|
768
|
+
const rootDir = join(tmpRoot, 'restart');
|
|
769
|
+
const a = makeOrchestrator({ rootDir });
|
|
770
|
+
const r1 = await a.createContainer(makeRequest('c1'));
|
|
771
|
+
const r2 = await a.createContainer(makeRequest('c2'));
|
|
772
|
+
await waitFor(() => a.isRunning(r1.dockerId));
|
|
773
|
+
await waitFor(() => a.isRunning(r2.dockerId));
|
|
774
|
+
|
|
775
|
+
// Kill c2 externally
|
|
776
|
+
const { pid: pid2 } = decodeDockerId(r2.dockerId);
|
|
777
|
+
process.kill(pid2, 'SIGKILL');
|
|
778
|
+
await waitFor(() => !isPidAlive(pid2));
|
|
779
|
+
|
|
780
|
+
// New orchestrator over the same rootDir, init()
|
|
781
|
+
const b = makeOrchestrator({ rootDir });
|
|
782
|
+
await b.init();
|
|
783
|
+
|
|
784
|
+
expect(await b.isRunning(r1.dockerId)).toBe(true);
|
|
785
|
+
expect(await b.isRunning(r2.dockerId)).toBe(false);
|
|
786
|
+
|
|
787
|
+
// Ports from both should be reserved (no overlapping allocations from `b`)
|
|
788
|
+
// Allocate three more containers via `b` and confirm none collide with
|
|
789
|
+
// c1/c2's existing ports.
|
|
790
|
+
const reservedPorts = new Set([
|
|
791
|
+
r1.p2pPort,
|
|
792
|
+
r2.p2pPort,
|
|
793
|
+
Number(r1.healthEndpoint.match(/:(\d+)/)![1]),
|
|
794
|
+
Number(r1.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
795
|
+
Number(r2.healthEndpoint.match(/:(\d+)/)![1]),
|
|
796
|
+
Number(r2.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
797
|
+
]);
|
|
798
|
+
const r3 = await b.createContainer(makeRequest('c3'));
|
|
799
|
+
const r3Ports = new Set([
|
|
800
|
+
r3.p2pPort,
|
|
801
|
+
Number(r3.healthEndpoint.match(/:(\d+)/)![1]),
|
|
802
|
+
Number(r3.metricsEndpoint.match(/:(\d+)/)![1]),
|
|
803
|
+
]);
|
|
804
|
+
for (const p of r3Ports) {
|
|
805
|
+
expect(reservedPorts.has(p)).toBe(false);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The orchestrator must spawn children that survive the spawning process
|
|
812
|
+
* itself exiting. The same-process restart test below does not exercise
|
|
813
|
+
* this — both orchestrators live in the same vitest worker, so the
|
|
814
|
+
* spawning process's pipe-read fds never close. This test genuinely exits
|
|
815
|
+
* the spawning process and asserts that the grandchild (a) stays alive
|
|
816
|
+
* past the parent's death and (b) keeps writing to its log file.
|
|
817
|
+
*
|
|
818
|
+
* Requires `yarn workspace @serfab/cadre-host build` to have been run —
|
|
819
|
+
* the helper script imports the built dist so it can run as a plain Node
|
|
820
|
+
* process without a TS loader.
|
|
821
|
+
*/
|
|
822
|
+
describe('child survives orchestrator exit', () => {
|
|
823
|
+
const orchestratorDistUrl = (() => {
|
|
824
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
825
|
+
const distDir = resolvePath(here, '..', '..', 'dist', 'orchestrator');
|
|
826
|
+
const distPath = join(distDir, 'host-process-orchestrator.js');
|
|
827
|
+
return {
|
|
828
|
+
distPath,
|
|
829
|
+
orchestratorUrl: pathToFileURL(distPath).href,
|
|
830
|
+
logRotatorUrl: pathToFileURL(join(distDir, 'log-rotator.js')).href,
|
|
831
|
+
};
|
|
832
|
+
})();
|
|
833
|
+
|
|
834
|
+
it.skipIf(!existsSync(orchestratorDistUrl.distPath))(
|
|
835
|
+
'grandchild stays alive and keeps logging after spawner process exits',
|
|
836
|
+
async () => {
|
|
837
|
+
const { orchestratorUrl, logRotatorUrl } = orchestratorDistUrl;
|
|
838
|
+
const rootDir = join(tmpRoot, 'cross');
|
|
839
|
+
mkdirSync(rootDir, { recursive: true });
|
|
840
|
+
|
|
841
|
+
const helperPath = join(tmpRoot, 'spawner.mjs');
|
|
842
|
+
writeFileSync(helperPath, buildSpawnerScript(orchestratorUrl, logRotatorUrl), 'utf8');
|
|
843
|
+
|
|
844
|
+
const request = {
|
|
845
|
+
containerId: 'cross1',
|
|
846
|
+
partyId: 'party-cross1',
|
|
847
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
848
|
+
profile: 'transaction',
|
|
849
|
+
};
|
|
850
|
+
const payload = {
|
|
851
|
+
rootDir,
|
|
852
|
+
entrypoint: scriptPath,
|
|
853
|
+
request,
|
|
854
|
+
// Ensure the fake child keeps ticking quickly so logs grow visibly
|
|
855
|
+
// in the ~300 ms observation window.
|
|
856
|
+
childEnv: { CHILD_TICK_MS: '20', CHILD_TICK_PAYLOAD: 'cross-tick' },
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// Run the helper as a normal (non-detached) Node child and wait for
|
|
860
|
+
// it to exit. The orchestrator's grandchild should survive.
|
|
861
|
+
const result = spawnSync(process.execPath, [helperPath, JSON.stringify(payload)], {
|
|
862
|
+
encoding: 'utf8',
|
|
863
|
+
timeout: 15000,
|
|
864
|
+
});
|
|
865
|
+
expect(result.status, `helper exited non-zero: stderr=${result.stderr}`).toBe(0);
|
|
866
|
+
|
|
867
|
+
const jsonLine = result.stdout
|
|
868
|
+
.split(/\r?\n/)
|
|
869
|
+
.reverse()
|
|
870
|
+
.find((line) => line.trim().startsWith('{'));
|
|
871
|
+
expect(jsonLine, `helper produced no JSON line: stdout=${result.stdout}`).toBeTruthy();
|
|
872
|
+
const handle = JSON.parse(jsonLine!) as {
|
|
873
|
+
dockerId: string;
|
|
874
|
+
pid: number;
|
|
875
|
+
workdir: string;
|
|
876
|
+
logPath: string;
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
// Immediate liveness
|
|
881
|
+
expect(isPidAlive(handle.pid)).toBe(true);
|
|
882
|
+
|
|
883
|
+
// POSIX delivers SIGPIPE on the child's NEXT write — give it time
|
|
884
|
+
// to attempt one (the fake child ticks every 20 ms).
|
|
885
|
+
await sleep(500);
|
|
886
|
+
expect(isPidAlive(handle.pid), 'grandchild died after spawner exit (likely SIGPIPE)').toBe(true);
|
|
887
|
+
|
|
888
|
+
// Logs must keep growing — proves stdio still works, not just PID.
|
|
889
|
+
const sizeA = statSync(handle.logPath).size;
|
|
890
|
+
await sleep(300);
|
|
891
|
+
const sizeB = statSync(handle.logPath).size;
|
|
892
|
+
expect(sizeB).toBeGreaterThan(sizeA);
|
|
893
|
+
} finally {
|
|
894
|
+
try { process.kill(handle.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
895
|
+
try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
// The helper spawn alone budgets 15 s; the surrounding waits add ~0.8 s
|
|
899
|
+
// more. Under the 5 s default the test could never reach its own timeout.
|
|
900
|
+
30_000,
|
|
901
|
+
);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
function buildSpawnerScript(orchestratorUrl: string, logRotatorUrl: string): string {
|
|
905
|
+
return `
|
|
906
|
+
import { HostProcessOrchestrator } from ${JSON.stringify(orchestratorUrl)};
|
|
907
|
+
import { defaultLogPath } from ${JSON.stringify(logRotatorUrl)};
|
|
908
|
+
|
|
909
|
+
const payload = JSON.parse(process.argv[2]);
|
|
910
|
+
const { rootDir, entrypoint, request, childEnv } = payload;
|
|
911
|
+
|
|
912
|
+
for (const [k, v] of Object.entries(childEnv ?? {})) {
|
|
913
|
+
process.env[k] = String(v);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const orch = new HostProcessOrchestrator({
|
|
917
|
+
rootDir,
|
|
918
|
+
portRange: { start: 19000, end: 19999 },
|
|
919
|
+
stopTimeoutMs: 1500,
|
|
920
|
+
spawn: { entrypoint },
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
const res = await orch.createContainer(request);
|
|
924
|
+
const pid = Number(res.dockerId.split(':')[0]);
|
|
925
|
+
const workdir = (await import('node:path')).join(rootDir, request.containerId);
|
|
926
|
+
const logPath = defaultLogPath(workdir);
|
|
927
|
+
|
|
928
|
+
process.stdout.write(JSON.stringify({
|
|
929
|
+
dockerId: res.dockerId,
|
|
930
|
+
pid,
|
|
931
|
+
workdir,
|
|
932
|
+
logPath,
|
|
933
|
+
}) + '\\n');
|
|
934
|
+
|
|
935
|
+
// Exit cleanly — the detached/unref'd child should outlive us.
|
|
936
|
+
process.exit(0);
|
|
937
|
+
`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
describe('dockerId encoding', () => {
|
|
941
|
+
it('encode/decode round-trips pid and token', () => {
|
|
942
|
+
const id = encodeDockerId(12345, 'abcdef');
|
|
943
|
+
expect(decodeDockerId(id)).toEqual({ pid: 12345, token: 'abcdef' });
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('rejects malformed ids', () => {
|
|
947
|
+
expect(() => decodeDockerId('notvalid')).toThrow();
|
|
948
|
+
expect(() => decodeDockerId(':oops')).toThrow();
|
|
949
|
+
expect(() => decodeDockerId('0:tok')).toThrow();
|
|
950
|
+
});
|
|
951
|
+
});
|