@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.
Files changed (113) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/strands/index.d.ts +7 -0
  57. package/dist/strands/index.js +6 -0
  58. package/dist/strands/index.js.map +1 -0
  59. package/dist/strands/strand-service.d.ts +50 -0
  60. package/dist/strands/strand-service.js +103 -0
  61. package/dist/strands/strand-service.js.map +1 -0
  62. package/dist/strands/types.d.ts +93 -0
  63. package/dist/strands/types.js +23 -0
  64. package/dist/strands/types.js.map +1 -0
  65. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  66. package/dist/ui/assets/index-C-iks41M.js +17 -0
  67. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  68. package/dist/ui/index.html +2 -2
  69. package/dist/update/release-key.d.ts +1 -1
  70. package/dist/update/release-key.js +1 -1
  71. package/package.json +5 -5
  72. package/src/__tests__/build-targets.test.ts +26 -0
  73. package/src/__tests__/global-setup.ts +51 -0
  74. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  75. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  76. package/src/__tests__/orchestrator.test.ts +387 -6
  77. package/src/auth/__tests__/self-label.test.ts +85 -0
  78. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  79. package/src/auth/__tests__/trust-circle.test.ts +45 -1
  80. package/src/auth/index.ts +2 -0
  81. package/src/auth/self-label.ts +74 -0
  82. package/src/auth/trust-circle.ts +45 -13
  83. package/src/bin/host.ts +1299 -1258
  84. package/src/donation/__tests__/donation-service.test.ts +836 -58
  85. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  86. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  87. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  88. package/src/donation/donation-service.ts +611 -41
  89. package/src/donation/donation-store.ts +7 -0
  90. package/src/donation/donation-supervisor.ts +388 -0
  91. package/src/donation/index.ts +15 -0
  92. package/src/donation/types.ts +23 -0
  93. package/src/index.ts +14 -0
  94. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  95. package/src/orchestrator/host-process-orchestrator.ts +336 -97
  96. package/src/orchestrator/node-identity.ts +40 -0
  97. package/src/orchestrator/port-allocator.ts +48 -0
  98. package/src/owner/owner-node-client.ts +31 -5
  99. package/src/server/__tests__/grants-route.test.ts +80 -6
  100. package/src/server/__tests__/strands-route.test.ts +233 -0
  101. package/src/server/error-handler.ts +24 -1
  102. package/src/server/events/types.ts +1 -0
  103. package/src/server/index.ts +16 -3
  104. package/src/server/routes/grants.ts +27 -3
  105. package/src/server/routes/strands.ts +54 -0
  106. package/src/strands/__tests__/strand-service.test.ts +165 -0
  107. package/src/strands/index.ts +14 -0
  108. package/src/strands/strand-service.ts +135 -0
  109. package/src/strands/types.ts +102 -0
  110. package/src/update/release-key.ts +1 -1
  111. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  112. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  113. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -12,7 +12,7 @@ import {
12
12
  readSync,
13
13
  closeSync,
14
14
  } from 'node:fs';
15
- import { join, resolve as resolvePath } from 'node:path';
15
+ import { join, resolve as resolvePath, sep } from 'node:path';
16
16
  import debug from 'debug';
17
17
  import pidusage from 'pidusage';
18
18
  import type {
@@ -23,7 +23,8 @@ import type {
23
23
  } from '@serfab/cadre-provider';
24
24
 
25
25
  import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
26
- import { PortAllocator } from './port-allocator.js';
26
+ import { ensureNodeIdentity } from './node-identity.js';
27
+ import { allocateNodePorts, PortAllocator } from './port-allocator.js';
27
28
  import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
28
29
  import { isPidAlive } from './pid-liveness.js';
29
30
  import type { PushCredentials } from '@serfab/cadre-core';
@@ -53,6 +54,27 @@ const STARTUP_TOKEN_FILE = '.startup-token';
53
54
  const CONFIG_FILE = 'cadre.json';
54
55
  const LIVENESS_POLL_MS = 100;
55
56
 
57
+ const CADRE_ENV_PREFIX = 'CADRE_';
58
+
59
+ /**
60
+ * Manager's own `process.env` with every `CADRE_*` key removed. A child node
61
+ * takes its whole configuration from the config file this orchestrator writes
62
+ * plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
63
+ * `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
64
+ * would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
65
+ * reconfigure every spawned child. Scrubbing the whole prefix rather than a
66
+ * list of known keys means a new cli env var can't reintroduce the leak by
67
+ * being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
68
+ * still pass through untouched.
69
+ */
70
+ function scrubbedParentEnv(): NodeJS.ProcessEnv {
71
+ const env = { ...process.env };
72
+ for (const key of Object.keys(env)) {
73
+ if (key.startsWith(CADRE_ENV_PREFIX)) delete env[key];
74
+ }
75
+ return env;
76
+ }
77
+
56
78
  /** Fixed friendly id for the admin's owner node. */
57
79
  export const OWNER_CONTAINER_ID = 'owner';
58
80
 
@@ -153,13 +175,7 @@ export class HostProcessOrchestrator implements Orchestrator {
153
175
  };
154
176
  this.handles.set(handle.dockerId, handle);
155
177
  // Always reserve ports — even for dead handles, until the caller cleans them up
156
- this.portAllocator.markUsed(persisted.ports.health);
157
- this.portAllocator.markUsed(persisted.ports.metrics);
158
- this.portAllocator.markUsed(persisted.ports.p2p);
159
- // `admin` is absent on handles persisted before this build.
160
- if (typeof persisted.ports.admin === 'number') {
161
- this.portAllocator.markUsed(persisted.ports.admin);
162
- }
178
+ this.reservePorts(persisted.ports);
163
179
  }
164
180
  log('init: re-attached %d handles', state.handles.length);
165
181
  }
@@ -206,50 +222,152 @@ export class HostProcessOrchestrator implements Orchestrator {
206
222
  return () => { this.stateListeners.delete(listener); };
207
223
  }
208
224
 
209
- /** Handles that init() found dead (PID gone or token mismatch). */
210
- listDeadHandles(): PersistedHandle[] {
211
- const dead: PersistedHandle[] = [];
212
- for (const h of this.handles.values()) {
213
- if (!h.alive) {
214
- dead.push(this.toPersisted(h));
215
- }
225
+ /**
226
+ * Spawn a generic managed node — the donated-node path (the requester's cadre,
227
+ * pinned to the *requester's* owner key). The node is given its own protobuf
228
+ * identity key inside its workdir (`ensureNodeIdentity`, reused across
229
+ * re-spawns of the same containerId), which is what makes its peer id stable
230
+ * across restarts AND what makes its node-local stores durable: `cadre-cli
231
+ * start` opens the file-backed bootstrap-peer and trusted-owner stores only
232
+ * when a protobuf identity key file is configured, and puts them beside it.
233
+ * Terminating the loan (`removeContainer`) deletes the workdir, so the key and
234
+ * both stores go with it.
235
+ */
236
+ async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
237
+ const workdir = this.workdirFor(request.containerId);
238
+ // Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
239
+ // existence as a side effect of writing the key. Only a spawn that found
240
+ // nothing here may delete anything on its way out — a re-spawn is REUSING
241
+ // the identity key and node-local stores this directory holds, and they are
242
+ // the whole reason the node comes back as the same peer. `false` is
243
+ // therefore the safe default: a directory that exists for any reason
244
+ // (respawn, an earlier failed provision, an operator's file) is never
245
+ // touched by the unwind below.
246
+ const isNewWorkdir = !existsSync(workdir);
247
+ let dropped: Handle[] = [];
248
+ let ports: NodePorts | undefined;
249
+ try {
250
+ // Identity BEFORE any port is reserved: it is the one step here that can
251
+ // fail on its own (an unreadable or undecodable identity.key throws rather
252
+ // than silently re-keying), and everything past the allocation is either
253
+ // infallible or releases the ports itself. Allocating first would leak four
254
+ // ports from a bounded range on every failed provision attempt.
255
+ const identity = await ensureNodeIdentity(workdir);
256
+ log('container %s identity peerId=%s', request.containerId, identity.peerId);
257
+ // A node started with pinned owner keys belongs to a FOREIGN cadre (the
258
+ // node-donation flow: it trusts the requester's owner key, not the host's).
259
+ // The host's FCM/APNs credentials are minted for the host owner's own app
260
+ // and are meaningless to a foreign cadre — so donated nodes get NO push
261
+ // block. (Per-grantee push creds would be a future ticket; none in v1.)
262
+ const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
263
+ // Only storage-profile nodes participate in strands and thus fan out push
264
+ // wakes — a transaction-only node need not carry credentials. Resolved
265
+ // BEFORE the drop below: it is the last `await` on this path, which is what
266
+ // keeps the drop → launch window synchronous (see restoreDroppedHandles).
267
+ const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
268
+ // Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
269
+ // `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
270
+ // node will accept the foreign-authority-signed seed presented via POST /seed.
271
+ const extraEnv = request.pinnedOwnerKeys?.length
272
+ ? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
273
+ : undefined;
274
+
275
+ // Nothing from here to the launch is `await`ed — which is what keeps
276
+ // `restoreDroppedHandles`'s documented precondition true even though the
277
+ // `try` now opens further up. Do not add an `await` past this line.
278
+ dropped = this.dropStaleHandle(request.containerId);
279
+ // NOTE: the p2p port is re-allocated per spawn, so a re-spawned donated
280
+ // node keeps its peer id but may announce a different port. Recoverable —
281
+ // it dials out to its retained bootstrap peers and republishes its own
282
+ // CadrePeer row once connected. If that reconnect ever proves too slow,
283
+ // pin the p2p port per containerId (see donated-node-respawn-core).
284
+ ports = allocateNodePorts(this.portAllocator);
285
+ return this.launchChild({
286
+ containerId: request.containerId,
287
+ partyId: request.partyId,
288
+ profile: request.profile,
289
+ ports,
290
+ owner: false,
291
+ buildConfig: () => this.buildChildConfig(request, workdir, push),
292
+ extraArgs: ['--identity-protobuf', identity.path],
293
+ ...(extraEnv ? { extraEnv } : {}),
294
+ ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
295
+ });
296
+ } catch (err) {
297
+ // Release BEFORE restoring: a re-spawn allocates the very ports the drop
298
+ // just freed, so restoring first and releasing second would hand the
299
+ // restored handle's own ports straight back to the allocator.
300
+ if (ports) this.releasePorts(ports);
301
+ // A no-op for the failures that happen before the drop (`dropped` is
302
+ // still empty).
303
+ this.restoreDroppedHandles(dropped);
304
+ // Safe because a throw out of `launchChild` means NO child is running:
305
+ // its throw surface ends where the child is spawned and its handle stored
306
+ // (everything past that line is best-effort), and the `if (!child.pid)`
307
+ // throw is on the no-process side too. So nothing is ever holding the
308
+ // directory this deletes.
309
+ if (isNewWorkdir) this.discardWorkdir(workdir);
310
+ throw err;
216
311
  }
217
- return dead;
218
312
  }
219
313
 
220
- async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
221
- const ports: NodePorts = {
222
- health: this.portAllocator.allocate(),
223
- metrics: this.portAllocator.allocate(),
224
- p2p: this.portAllocator.allocate(),
225
- admin: this.portAllocator.allocate(),
226
- };
227
- // A node started with pinned owner keys belongs to a FOREIGN cadre (the
228
- // node-donation flow: it trusts the requester's owner key, not the host's).
229
- // The host's FCM/APNs credentials are minted for the host owner's own app
230
- // and are meaningless to a foreign cadre — so donated nodes get NO push
231
- // block. (Per-grantee push creds would be a future ticket; none in v1.)
232
- const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
233
- // Only storage-profile nodes participate in strands and thus fan out push
234
- // wakes — a transaction-only node need not carry credentials.
235
- const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
236
- // Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
237
- // `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
238
- // node will accept the foreign-authority-signed seed presented via POST /seed.
239
- const extraEnv = request.pinnedOwnerKeys?.length
240
- ? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
241
- : undefined;
242
- return this.launchChild({
243
- containerId: request.containerId,
244
- partyId: request.partyId,
245
- profile: request.profile,
246
- ports,
247
- owner: false,
248
- buildConfig: (workdir) => this.buildChildConfig(request, workdir, push),
249
- extraArgs: [],
250
- ...(extraEnv ? { extraEnv } : {}),
251
- ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
252
- });
314
+ /**
315
+ * Remove the working directory a spawn created for `containerId` when no
316
+ * handle owns it — the cleanup of last resort for a donation record that
317
+ * never got a `dockerId` (the host died between `ensureNodeIdentity` and the
318
+ * handle write, so `createContainer`'s own unwind never ran). Returns whether
319
+ * anything was removed.
320
+ *
321
+ * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
322
+ * to await.
323
+ */
324
+ reclaimWorkdir(containerId: string): boolean {
325
+ // The admin's own node is never reclaimed by this route: its workdir is a
326
+ // single fixed path reused by every restart and it holds the host's own
327
+ // control-DB storage.
328
+ if (containerId === OWNER_CONTAINER_ID) return false;
329
+ // A live handle owns that directory, and `removeContainer` — which stops
330
+ // the child first — is the only thing allowed to delete it.
331
+ if (this.resolveDockerId(containerId)) return false;
332
+ const workdir = this.workdirFor(containerId);
333
+ // Defence, not a live case: donation ids are `grn_<base64url>` and so can
334
+ // hold neither a path separator nor a dot. A caller that ever passes
335
+ // something else must not be able to walk out of `rootDir`.
336
+ if (!workdir.startsWith(this.rootDir + sep)) {
337
+ log('refusing to reclaim workdir outside rootDir: %s', workdir);
338
+ return false;
339
+ }
340
+ // Must be a DIRECTORY, not merely something that exists: `rootDir` also
341
+ // holds `state.json` (this orchestrator's own persisted handles), and
342
+ // `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
343
+ // defence class as the escape check above — no real containerId names a
344
+ // file — but the blast radius if one ever did is the whole handle map.
345
+ if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory()) return false;
346
+ this.discardWorkdir(workdir);
347
+ return true;
348
+ }
349
+
350
+ /**
351
+ * Best-effort removal of a workdir no handle owns. Logs, never throws: every
352
+ * caller is either unwinding a failure it must report instead of this one, or
353
+ * tearing a container down along a path where a surviving directory is a
354
+ * nuisance rather than a fault.
355
+ *
356
+ * `retries` sizes the wait for the OS to release a cwd handle — Windows drops
357
+ * it asynchronously after a child exits. `createContainer`'s unwind needs none
358
+ * of that (its throw surface ends before any child is spawned), but
359
+ * `reclaimWorkdir` can still meet an orphan process the previous host left
360
+ * running with this directory as its cwd, so it is not a *guaranteed*
361
+ * no-process path either — it simply logs and leaves the directory when the
362
+ * removal fails.
363
+ */
364
+ private discardWorkdir(workdir: string, retries = { maxRetries: 5, retryDelay: 100 }): void {
365
+ try {
366
+ rmSync(workdir, { recursive: true, force: true, ...retries });
367
+ log('discarded workdir %s', workdir);
368
+ } catch (err) {
369
+ log('failed to discard workdir %s: %o', workdir, err);
370
+ }
253
371
  }
254
372
 
255
373
  /**
@@ -297,40 +415,46 @@ export class HostProcessOrchestrator implements Orchestrator {
297
415
  this.persist();
298
416
  return toNodeInfo(existing);
299
417
  }
300
- if (existing) {
301
- // Drop the stale handle and release its ports — but DO NOT delete the
302
- // workdir: the owner node's control-DB storage lives there and must
303
- // survive a restart. launchChild reuses the same workdir.
304
- this.releasePorts(existing.ports);
305
- this.handles.delete(existing.dockerId);
306
- }
307
418
 
308
419
  const profile = config.profile ?? 'storage';
309
420
  // Re-resolve push credentials from the secret store on every (re-)spawn so a
310
421
  // restart picks up rotated keys and nothing raw is replayed from state.json.
422
+ // Resolved BEFORE the drop below so the drop → launch window stays
423
+ // synchronous (see restoreDroppedHandles).
311
424
  const push = await this.resolvePush();
312
- const ports: NodePorts = {
313
- health: this.portAllocator.allocate(),
314
- metrics: this.portAllocator.allocate(),
315
- admin: this.portAllocator.allocate(),
316
- // The owner node must listen on the configured libp2p port so the
317
- // NAT mapping (external → internal) lands on it. That port is outside
318
- // the managed range, so markUsed is a harmless no-op.
319
- p2p: config.libp2pPort,
320
- };
321
- this.portAllocator.markUsed(config.libp2pPort);
322
425
 
323
- const result = this.launchChild({
324
- containerId: OWNER_CONTAINER_ID,
325
- partyId: config.partyId,
326
- profile,
327
- ports,
328
- owner: true,
329
- buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
330
- extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
331
- });
332
- const handle = this.handles.get(result.dockerId)!;
333
- return toNodeInfo(handle);
426
+ // NOTE: the short-circuit above matches via `findOwnerHandle` (owner flag OR
427
+ // containerId), this drop only by containerId. Equivalent today — every
428
+ // owner handle is launched with OWNER_CONTAINER_ID. If an owner spawn ever
429
+ // takes a different containerId, the two would disagree and the handle the
430
+ // short-circuit found would survive here, leaking it and its ports.
431
+ const dropped = this.dropStaleHandle(OWNER_CONTAINER_ID);
432
+ let ports: NodePorts | undefined;
433
+ try {
434
+ // The owner node must listen on the configured libp2p port so the NAT
435
+ // mapping (external → internal) lands on it — hence the p2p override.
436
+ ports = allocateNodePorts(this.portAllocator, { p2p: config.libp2pPort });
437
+ const result = this.launchChild({
438
+ containerId: OWNER_CONTAINER_ID,
439
+ partyId: config.partyId,
440
+ profile,
441
+ ports,
442
+ owner: true,
443
+ buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
444
+ extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
445
+ });
446
+ const handle = this.handles.get(result.dockerId)!;
447
+ return toNodeInfo(handle);
448
+ } catch (err) {
449
+ // Release before restoring — see the same note in createContainer.
450
+ if (ports) this.releasePorts(ports);
451
+ this.restoreDroppedHandles(dropped);
452
+ // Deliberately NOT `discardWorkdir` — the asymmetry with createContainer
453
+ // is the point. `<rootDir>/owner` is a single fixed path reused by every
454
+ // restart and holds the host's own control-DB storage, so it neither
455
+ // grows without bound nor is safe to delete on a failed spawn.
456
+ throw err;
457
+ }
334
458
  }
335
459
 
336
460
  /** Endpoint + bearer for the owner node's admin channel, if spawned. */
@@ -378,8 +502,18 @@ export class HostProcessOrchestrator implements Orchestrator {
378
502
 
379
503
  /**
380
504
  * Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
381
- * The caller has already allocated `ports`. Releases the ports on a
382
- * synchronous spawn failure.
505
+ *
506
+ * The caller has already allocated `ports` and **owns their lifetime**: this
507
+ * method never releases them, so every caller must release on any throw out
508
+ * of here (it has to unwind its handle drop on the same path anyway — see
509
+ * `restoreDroppedHandles`).
510
+ *
511
+ * That contract holds because this method throws only *before* the child is
512
+ * spawned and its handle stored — everything after that point is
513
+ * best-effort. Anything new added at the tail must keep it that way.
514
+ *
515
+ * Entirely synchronous, which is what makes that unwind sound. Do not add an
516
+ * `await` here.
383
517
  */
384
518
  private launchChild(opts: {
385
519
  containerId: string;
@@ -394,7 +528,7 @@ export class HostProcessOrchestrator implements Orchestrator {
394
528
  memoryLimit?: string;
395
529
  }): OrchestratorCreateResult {
396
530
  const { containerId, ports } = opts;
397
- const workdir = join(this.rootDir, containerId);
531
+ const workdir = this.workdirFor(containerId);
398
532
  mkdirSync(workdir, { recursive: true });
399
533
  mkdirSync(join(workdir, 'storage'), { recursive: true });
400
534
 
@@ -419,15 +553,23 @@ export class HostProcessOrchestrator implements Orchestrator {
419
553
  const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
420
554
 
421
555
  const env: NodeJS.ProcessEnv = {
422
- ...process.env,
423
- // Caller-supplied extras first — the fixed per-child vars below always win
424
- // so extraEnv can never clobber the seed/startup tokens or port wiring.
556
+ // Scrubbed, not raw process.env: every CADRE_* config-override key is
557
+ // stripped so a var set on the manager can't silently reconfigure every
558
+ // child (see scrubbedParentEnv). Caller-supplied extras come next — the
559
+ // fixed per-child vars below always win so extraEnv can never clobber
560
+ // the seed/startup tokens or port wiring.
561
+ ...scrubbedParentEnv(),
425
562
  ...opts.extraEnv,
426
563
  CADRE_STARTUP_TOKEN: token,
427
564
  CADRE_HEALTH_PORT: String(ports.health),
428
565
  CADRE_METRICS_PORT: String(ports.metrics),
429
566
  CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
430
567
  CADRE_SEED_TOKEN: seedToken,
568
+ // Pin each child's node-local state (trusted-owner anchor, retained
569
+ // cold-start dial targets) to its OWN workdir. This is the same value the
570
+ // cli would derive by default (the config file lives here too), but stated
571
+ // explicitly rather than relying on the scrub above (belt and suspenders).
572
+ CADRE_NODE_STATE_DIR: workdir,
431
573
  };
432
574
  if (heapMB !== undefined) {
433
575
  env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
@@ -462,18 +604,17 @@ export class HostProcessOrchestrator implements Orchestrator {
462
604
  env,
463
605
  });
464
606
  } catch (err) {
465
- // Synchronous spawn failure — release the fd and the reserved ports
466
- // before propagating, so a bad-args bug doesn't leak resources.
607
+ // Synchronous spawn failure — close the fd before propagating. This is
608
+ // the ONLY fd-leak guard on this path: nothing between the `openSync`
609
+ // above and the `spawn` can throw, so no other catch is needed. Keep it
610
+ // that way, or add one. (The ports are the caller's to release.)
467
611
  closeSync(logFd);
468
- this.releasePorts(ports);
469
612
  throw err;
470
613
  }
471
614
  // Always release the parent's copy of the fd — the child has its own.
472
615
  closeSync(logFd);
473
616
 
474
617
  if (!child.pid) {
475
- // Spawn returned without a pid — release reservations and report.
476
- this.releasePorts(ports);
477
618
  throw new Error(`Failed to spawn child for container ${containerId}`);
478
619
  }
479
620
 
@@ -504,7 +645,19 @@ export class HostProcessOrchestrator implements Orchestrator {
504
645
  });
505
646
 
506
647
  this.handles.set(dockerId, handle);
507
- this.persist();
648
+ // Past this line the launch has SUCCEEDED: the child is running and this
649
+ // handle owns its ports. So the state mirror is best-effort — a throw here
650
+ // would reach the caller's unwind, which would release ports the live child
651
+ // is bound to and restore the handle this spawn just replaced, leaving two
652
+ // handles for one container and one of them holding unreserved ports. A
653
+ // lost write self-heals: the next persist() (any stop/remove/spawn)
654
+ // rewrites the whole map. Until then a manager restart would fail to
655
+ // re-attach this child, hence the log.
656
+ try {
657
+ this.persist();
658
+ } catch (err) {
659
+ log('failed to persist state after launching container %s: %o', containerId, err);
660
+ }
508
661
  this.emitStateChange(handle);
509
662
 
510
663
  log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
@@ -520,6 +673,81 @@ export class HostProcessOrchestrator implements Orchestrator {
520
673
  };
521
674
  }
522
675
 
676
+ /**
677
+ * Drop any handle left over from a previous spawn of the same `containerId`
678
+ * and release its ports. Handles are keyed by the per-spawn `dockerId`, so
679
+ * without this a re-spawn (donated-node respawn) would strand the prior
680
+ * handle in the map forever, leaking four ports from a bounded range each
681
+ * time. Mirrors the same cleanup in `ensureOwnerNode`.
682
+ *
683
+ * DO NOT delete the workdir: the identity key and node-local stores
684
+ * (trusted-owner anchor, retained cold-start dial targets) live there and are
685
+ * the whole reason a respawn comes back as the same node. `launchChild`
686
+ * reuses the same workdir.
687
+ *
688
+ * NOTE: releasing the ports hands them straight back to the allocator, so a
689
+ * re-spawn while the *previous* child is still listening can bind-clash. Every
690
+ * caller today re-spawns only a container it has established is not running
691
+ * (`DonationService.respawn`, `ensureOwnerNode`). If a caller ever needs to
692
+ * replace a live child, stop it first — or hold the ports until its exit.
693
+ *
694
+ * **The drop is reversible.** It happens before the launch can fail, so the
695
+ * dropped handles are returned and every caller restores them via
696
+ * {@link restoreDroppedHandles} when the launch throws — otherwise the caller
697
+ * would be left holding a dockerId this orchestrator no longer knows, and the
698
+ * node's workdir could never be reclaimed. On a *successful* spawn the drop
699
+ * stands: `DonationService.abandonRespawn` depends on the old handle being
700
+ * gone by then.
701
+ */
702
+ private dropStaleHandle(containerId: string): Handle[] {
703
+ const dropped: Handle[] = [];
704
+ for (const h of this.handles.values()) {
705
+ if (h.containerId !== containerId) continue;
706
+ log('dropping stale handle %s for container %s', h.dockerId, containerId);
707
+ this.releasePorts(h.ports);
708
+ this.handles.delete(h.dockerId);
709
+ dropped.push(h);
710
+ }
711
+ return dropped;
712
+ }
713
+
714
+ /**
715
+ * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
716
+ * handle back in the map and re-reserve its four ports, leaving host state
717
+ * exactly as the failed attempt found it.
718
+ *
719
+ * **Precondition: the drop → launch window is synchronous.** Restoring is
720
+ * sound only because nothing can have taken those ports or run cleanup
721
+ * against the handle in between — `launchChild` contains no `await` and both
722
+ * callers resolve push credentials *before* their drop. `launchChild`'s half
723
+ * is enforced: its declared return type is not a Promise, so adding an
724
+ * `await` there is a compile error. The callers' half is not — an `await`
725
+ * slipped between a drop and its launch silently breaks this.
726
+ *
727
+ * No `persist()` here: `state.json` is rewritten only by `launchChild` and
728
+ * only *after* the new handle is stored, so throughout the failure window the
729
+ * on-disk state still lists the dropped handle — this restore realigns memory
730
+ * to disk rather than diverging from it.
731
+ *
732
+ * NOTE: a restored handle keeps `alive: true` even if its child exited while
733
+ * it was out of the map (`launchChild`'s exit listener looks the handle up by
734
+ * dockerId and finds nothing). Only reachable by re-spawning a container
735
+ * whose child is still live, which `dropStaleHandle`'s note above already
736
+ * forbids.
737
+ */
738
+ private restoreDroppedHandles(dropped: Handle[]): void {
739
+ for (const h of dropped) {
740
+ log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
741
+ this.handles.set(h.dockerId, h);
742
+ this.reservePorts(h.ports);
743
+ }
744
+ }
745
+
746
+ /** A managed node's own directory under `rootDir` — config, log, storage, identity. */
747
+ private workdirFor(containerId: string): string {
748
+ return join(this.rootDir, containerId);
749
+ }
750
+
523
751
  /** Locate the owner node's handle, if one has been spawned. */
524
752
  private findOwnerHandle(): Handle | undefined {
525
753
  for (const h of this.handles.values()) {
@@ -528,6 +756,20 @@ export class HostProcessOrchestrator implements Orchestrator {
528
756
  return undefined;
529
757
  }
530
758
 
759
+ /**
760
+ * Inverse of {@link releasePorts} — hold a handle's whole port set against
761
+ * the allocator. Used both to rehydrate on `init` and to undo a drop whose
762
+ * launch then failed.
763
+ */
764
+ private reservePorts(ports: NodePorts): void {
765
+ this.portAllocator.markUsed(ports.health);
766
+ this.portAllocator.markUsed(ports.metrics);
767
+ this.portAllocator.markUsed(ports.p2p);
768
+ // `admin` is absent on handles persisted before this build; markUsed would
769
+ // otherwise put `undefined` in the used-set.
770
+ if (typeof ports.admin === 'number') this.portAllocator.markUsed(ports.admin);
771
+ }
772
+
531
773
  private releasePorts(ports: NodePorts): void {
532
774
  this.portAllocator.release(ports.health);
533
775
  this.portAllocator.release(ports.metrics);
@@ -598,13 +840,10 @@ export class HostProcessOrchestrator implements Orchestrator {
598
840
 
599
841
  this.releasePorts(handle.ports);
600
842
 
601
- // On Windows the OS releases the child's cwd handle asynchronously, so
602
- // retry rmSync to give the kernel a chance to drop it.
603
- try {
604
- rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
605
- } catch (err) {
606
- log('failed to remove workdir %s: %o', handle.workdir, err);
607
- }
843
+ // Longer retries than the other callers': this is the one path that has
844
+ // just killed a child which held this directory as its cwd, and on Windows
845
+ // the OS releases that handle asynchronously.
846
+ this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
608
847
 
609
848
  this.handles.delete(dockerId);
610
849
  this.persist();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Per-node identity keys for orchestrator-managed children.
3
+ *
4
+ * A donated node joins a *foreign* cadre: the requester approves its peer id
5
+ * and its seed nominates dial addresses keyed to that peer id. Both bind to the
6
+ * identity, so a node that generated a fresh libp2p keypair per process would
7
+ * become a stranger to the cadre that admitted it on every restart. It also
8
+ * needs a stable on-disk home for its node-local stores — `cadre-cli start`
9
+ * opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore` only when a protobuf
10
+ * identity key file is configured.
11
+ *
12
+ * So every managed node gets its own `identity.key` inside its workdir, written
13
+ * once and reused thereafter. The workdir is removed when the loan is
14
+ * terminated (`removeContainer`), so nothing the node persisted outlives it.
15
+ */
16
+
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ import { generateIdentity, loadIdentity, type IdentityRecord } from '../installer/identity.js';
21
+
22
+ const IDENTITY_FILE = 'identity.key';
23
+
24
+ /** Path to a managed node's own protobuf identity key inside its workdir. */
25
+ export function nodeIdentityPath(workdir: string): string {
26
+ return join(workdir, IDENTITY_FILE);
27
+ }
28
+
29
+ /**
30
+ * Ensure `<workdir>/identity.key` exists, generating an Ed25519 protobuf key on
31
+ * first call and REUSING it on every later call. Reuse is the load-bearing
32
+ * property: a re-spawn of the same container must land on the same peer id.
33
+ */
34
+ export async function ensureNodeIdentity(workdir: string): Promise<{ path: string; peerId: string }> {
35
+ const path = nodeIdentityPath(workdir);
36
+ const record: IdentityRecord = existsSync(path)
37
+ ? loadIdentity(path)
38
+ : await generateIdentity(path);
39
+ return { path, peerId: record.peerId };
40
+ }
@@ -1,3 +1,5 @@
1
+ import type { NodePorts } from './types.js';
2
+
1
3
  /**
2
4
  * Range-based port tracker. Allocations are in-memory; on orchestrator
3
5
  * restart the caller rehydrates the used-set from persisted state via
@@ -41,3 +43,49 @@ export class PortAllocator {
41
43
  return this.usedPorts.has(port);
42
44
  }
43
45
  }
46
+
47
+ /**
48
+ * Allocation order for a node's port set. Fixed so that swapping a caller onto
49
+ * {@link allocateNodePorts} does not shift the ports an existing deployment
50
+ * already handed out.
51
+ */
52
+ const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin'] as const;
53
+
54
+ /**
55
+ * Allocate a full NodePorts set atomically — a mid-way failure (an exhausted
56
+ * range) releases everything already taken rather than stranding the ports it
57
+ * got before the throw. `overrides` supply a port instead of allocating one
58
+ * (the owner node's p2p port, which is fixed by NAT config).
59
+ *
60
+ * Overrides are reserved FIRST, before any allocation, so an override that
61
+ * happens to sit inside the managed range cannot also be handed out to another
62
+ * key. Reserving is `markUsed`, a documented no-op outside the range — which is
63
+ * the production case for the owner node's libp2p port, and why reserving first
64
+ * leaves every real port assignment unchanged.
65
+ */
66
+ export function allocateNodePorts(
67
+ allocator: PortAllocator,
68
+ overrides?: Partial<NodePorts>,
69
+ ): NodePorts {
70
+ const reserved: number[] = [];
71
+ const ports: Partial<NodePorts> = {};
72
+ try {
73
+ for (const key of NODE_PORT_KEYS) {
74
+ const override = overrides?.[key];
75
+ if (override === undefined) continue;
76
+ allocator.markUsed(override);
77
+ reserved.push(override);
78
+ ports[key] = override;
79
+ }
80
+ for (const key of NODE_PORT_KEYS) {
81
+ if (ports[key] !== undefined) continue;
82
+ const port = allocator.allocate();
83
+ reserved.push(port);
84
+ ports[key] = port;
85
+ }
86
+ } catch (err) {
87
+ for (const port of reserved) allocator.release(port);
88
+ throw err;
89
+ }
90
+ return ports as NodePorts;
91
+ }