@serfab/cadre-host 0.9.0 → 0.10.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/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/self-label.d.ts +36 -0
- package/dist/auth/self-label.js +57 -0
- package/dist/auth/self-label.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +32 -5
- package/dist/auth/trust-circle.js +31 -11
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/bin/host.js +41 -5
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
- package/dist/donation/__tests__/fake-orchestrator.js +180 -0
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
- package/dist/donation/donation-service.d.ts +237 -12
- package/dist/donation/donation-service.js +491 -34
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +7 -0
- package/dist/donation/donation-store.js +7 -0
- package/dist/donation/donation-store.js.map +1 -1
- package/dist/donation/donation-supervisor.d.ts +167 -0
- package/dist/donation/donation-supervisor.js +341 -0
- package/dist/donation/donation-supervisor.js.map +1 -0
- package/dist/donation/index.d.ts +4 -2
- package/dist/donation/index.js +2 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +26 -0
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
- package/dist/orchestrator/host-process-orchestrator.js +341 -98
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +26 -0
- package/dist/orchestrator/node-identity.js +36 -0
- package/dist/orchestrator/node-identity.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +14 -0
- package/dist/orchestrator/port-allocator.js +45 -0
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/owner/owner-node-client.d.ts +20 -5
- package/dist/owner/owner-node-client.js +21 -4
- package/dist/owner/owner-node-client.js.map +1 -1
- package/dist/server/error-handler.d.ts +7 -1
- package/dist/server/error-handler.js +23 -1
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +9 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants.js +22 -3
- package/dist/server/routes/grants.js.map +1 -1
- package/dist/server/routes/strands.d.ts +20 -0
- package/dist/server/routes/strands.js +38 -0
- package/dist/server/routes/strands.js.map +1 -0
- package/dist/strands/index.d.ts +7 -0
- package/dist/strands/index.js +6 -0
- package/dist/strands/index.js.map +1 -0
- package/dist/strands/strand-service.d.ts +50 -0
- package/dist/strands/strand-service.js +103 -0
- package/dist/strands/strand-service.js.map +1 -0
- package/dist/strands/types.d.ts +93 -0
- package/dist/strands/types.js +23 -0
- package/dist/strands/types.js.map +1 -0
- package/dist/ui/assets/index-BVoqLjyX.css +1 -0
- package/dist/ui/assets/index-C-iks41M.js +17 -0
- package/dist/ui/assets/index-C-iks41M.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/release-key.d.ts +1 -1
- package/dist/update/release-key.js +1 -1
- package/package.json +5 -5
- package/src/__tests__/build-targets.test.ts +26 -0
- package/src/__tests__/global-setup.ts +51 -0
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- package/src/__tests__/orchestrator.test.ts +387 -6
- package/src/auth/__tests__/self-label.test.ts +85 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
- package/src/auth/__tests__/trust-circle.test.ts +45 -1
- package/src/auth/index.ts +2 -0
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle.ts +45 -13
- package/src/bin/host.ts +1299 -1258
- package/src/donation/__tests__/donation-service.test.ts +836 -58
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
- package/src/donation/__tests__/fake-orchestrator.ts +225 -0
- package/src/donation/donation-service.ts +611 -41
- package/src/donation/donation-store.ts +7 -0
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/index.ts +15 -0
- package/src/donation/types.ts +23 -0
- package/src/index.ts +14 -0
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +336 -97
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/port-allocator.ts +48 -0
- package/src/owner/owner-node-client.ts +31 -5
- package/src/server/__tests__/grants-route.test.ts +80 -6
- package/src/server/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +24 -1
- package/src/server/events/types.ts +1 -0
- package/src/server/index.ts +16 -3
- package/src/server/routes/grants.ts +27 -3
- package/src/server/routes/strands.ts +54 -0
- package/src/strands/__tests__/strand-service.test.ts +165 -0
- package/src/strands/index.ts +14 -0
- package/src/strands/strand-service.ts +135 -0
- package/src/strands/types.ts +102 -0
- package/src/update/release-key.ts +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +0 -14
- package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
- package/dist/ui/assets/index-BwU5mJGF.css +0 -1
|
@@ -14,11 +14,12 @@ import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
15
|
|
|
16
16
|
import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
17
|
-
import { PortAllocator } from '../orchestrator/port-allocator.js';
|
|
17
|
+
import { allocateNodePorts, PortAllocator } from '../orchestrator/port-allocator.js';
|
|
18
18
|
import { rotateOnDisk } from '../orchestrator/log-rotator.js';
|
|
19
19
|
import { decodeDockerId, encodeDockerId } from '../orchestrator/types.js';
|
|
20
20
|
import { StateStore } from '../orchestrator/state-store.js';
|
|
21
21
|
import { isPidAlive } from '../orchestrator/pid-liveness.js';
|
|
22
|
+
import { loadIdentity } from '../installer/identity.js';
|
|
22
23
|
|
|
23
24
|
const FAKE_CHILD = `
|
|
24
25
|
import fs from 'node:fs';
|
|
@@ -160,6 +161,338 @@ describe('HostProcessOrchestrator.createContainer', () => {
|
|
|
160
161
|
});
|
|
161
162
|
});
|
|
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
|
+
|
|
163
496
|
describe('HostProcessOrchestrator.isRunning', () => {
|
|
164
497
|
it('is true after spawn, false after kill, false on tampered token', async () => {
|
|
165
498
|
const orch = makeOrchestrator();
|
|
@@ -253,6 +586,16 @@ describe('HostProcessOrchestrator.removeContainer', () => {
|
|
|
253
586
|
});
|
|
254
587
|
});
|
|
255
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
|
+
|
|
256
599
|
describe('HostProcessOrchestrator.getStats', () => {
|
|
257
600
|
it('returns plausible numbers with zero network counters', async () => {
|
|
258
601
|
const orch = makeOrchestrator();
|
|
@@ -266,7 +609,7 @@ describe('HostProcessOrchestrator.getStats', () => {
|
|
|
266
609
|
expect(stats.memoryBytes).toBeGreaterThan(0);
|
|
267
610
|
expect(stats.networkRxBytes).toBe(0);
|
|
268
611
|
expect(stats.networkTxBytes).toBe(0);
|
|
269
|
-
});
|
|
612
|
+
}, STATS_TIMEOUT_MS);
|
|
270
613
|
});
|
|
271
614
|
|
|
272
615
|
describe('HostProcessOrchestrator log rotation at spawn', () => {
|
|
@@ -381,6 +724,45 @@ describe('PortAllocator', () => {
|
|
|
381
724
|
});
|
|
382
725
|
});
|
|
383
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
|
+
|
|
384
766
|
describe('Restart recovery (init)', () => {
|
|
385
767
|
it('re-attaches to surviving children and reports dead ones', async () => {
|
|
386
768
|
const rootDir = join(tmpRoot, 'restart');
|
|
@@ -402,10 +784,6 @@ describe('Restart recovery (init)', () => {
|
|
|
402
784
|
expect(await b.isRunning(r1.dockerId)).toBe(true);
|
|
403
785
|
expect(await b.isRunning(r2.dockerId)).toBe(false);
|
|
404
786
|
|
|
405
|
-
const dead = b.listDeadHandles();
|
|
406
|
-
expect(dead.map((d) => d.dockerId)).toContain(r2.dockerId);
|
|
407
|
-
expect(dead.map((d) => d.dockerId)).not.toContain(r1.dockerId);
|
|
408
|
-
|
|
409
787
|
// Ports from both should be reserved (no overlapping allocations from `b`)
|
|
410
788
|
// Allocate three more containers via `b` and confirm none collide with
|
|
411
789
|
// c1/c2's existing ports.
|
|
@@ -517,6 +895,9 @@ describe('child survives orchestrator exit', () => {
|
|
|
517
895
|
try { rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* ignore */ }
|
|
518
896
|
}
|
|
519
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,
|
|
520
901
|
);
|
|
521
902
|
});
|
|
522
903
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { SELF_LABEL, ensureSelfLabel } from '../self-label.js';
|
|
7
|
+
import { TrustCircleStore } from '../trust-circle-store.js';
|
|
8
|
+
|
|
9
|
+
let tmpRoot: string;
|
|
10
|
+
let store: TrustCircleStore;
|
|
11
|
+
|
|
12
|
+
const NOW = new Date('2025-06-01T12:00:00.000Z');
|
|
13
|
+
const now = (): Date => NOW;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-self-label-'));
|
|
17
|
+
store = new TrustCircleStore(tmpRoot);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('ensureSelfLabel', () => {
|
|
25
|
+
it('writes a self-flagged label for the owner peer ID', async () => {
|
|
26
|
+
const peerId = await ensureSelfLabel({ store, getPeerId: async () => '12D3KooWOwner', now });
|
|
27
|
+
|
|
28
|
+
expect(peerId).toBe('12D3KooWOwner');
|
|
29
|
+
expect(store.getMember('12D3KooWOwner')).toEqual({
|
|
30
|
+
peerId: '12D3KooWOwner',
|
|
31
|
+
label: SELF_LABEL,
|
|
32
|
+
addedAt: NOW.toISOString(),
|
|
33
|
+
self: true,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps an admin-renamed label on a second run', async () => {
|
|
38
|
+
store.addMember({ peerId: '12D3KooWOwner', label: 'Basement PC', addedAt: 't0', self: true });
|
|
39
|
+
|
|
40
|
+
await ensureSelfLabel({ store, getPeerId: async () => '12D3KooWOwner', now });
|
|
41
|
+
|
|
42
|
+
expect(store.getMember('12D3KooWOwner')).toMatchObject({ label: 'Basement PC', addedAt: 't0', self: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('stamps the self flag onto a pre-existing unflagged row', async () => {
|
|
46
|
+
store.addMember({ peerId: '12D3KooWOwner', label: 'Basement PC', addedAt: 't0' });
|
|
47
|
+
|
|
48
|
+
await ensureSelfLabel({ store, getPeerId: async () => '12D3KooWOwner', now });
|
|
49
|
+
|
|
50
|
+
expect(store.getMember('12D3KooWOwner')).toEqual({
|
|
51
|
+
peerId: '12D3KooWOwner',
|
|
52
|
+
label: 'Basement PC',
|
|
53
|
+
addedAt: 't0',
|
|
54
|
+
self: true,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('drops a stale self label after the node identity changes', async () => {
|
|
59
|
+
store.addMember({ peerId: '12D3KooWOld', label: SELF_LABEL, addedAt: 't0', self: true });
|
|
60
|
+
store.addMember({ peerId: '12D3KooWPhone', label: "Mom's phone", addedAt: 't1' });
|
|
61
|
+
|
|
62
|
+
await ensureSelfLabel({ store, getPeerId: async () => '12D3KooWNew', now });
|
|
63
|
+
|
|
64
|
+
expect(store.getMember('12D3KooWOld')).toBeUndefined();
|
|
65
|
+
expect(store.getMember('12D3KooWNew')).toMatchObject({ label: SELF_LABEL, self: true });
|
|
66
|
+
// Non-self labels are untouched.
|
|
67
|
+
expect(store.getMember('12D3KooWPhone')).toMatchObject({ label: "Mom's phone" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('writes nothing when the node is not ready yet (empty peer ID)', async () => {
|
|
71
|
+
const peerId = await ensureSelfLabel({ store, getPeerId: async () => '', now });
|
|
72
|
+
|
|
73
|
+
expect(peerId).toBeUndefined();
|
|
74
|
+
expect(store.listMembers()).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('propagates a getPeerId failure to the caller', async () => {
|
|
78
|
+
await expect(ensureSelfLabel({
|
|
79
|
+
store,
|
|
80
|
+
getPeerId: async () => { throw new Error('owner node unavailable'); },
|
|
81
|
+
now,
|
|
82
|
+
})).rejects.toThrow('owner node unavailable');
|
|
83
|
+
expect(store.listMembers()).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|