@serfab/cadre-host 1.14.0 → 1.16.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 (107) hide show
  1. package/README.md +19 -4
  2. package/dist/bin/host.js +32 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/bin/nat-output.d.ts +5 -0
  5. package/dist/bin/nat-output.js +12 -1
  6. package/dist/bin/nat-output.js.map +1 -1
  7. package/dist/bin/ui-access.d.ts +22 -0
  8. package/dist/bin/ui-access.js +44 -0
  9. package/dist/bin/ui-access.js.map +1 -0
  10. package/dist/hosted/claim-details.d.ts +25 -3
  11. package/dist/hosted/claim-details.js +31 -37
  12. package/dist/hosted/claim-details.js.map +1 -1
  13. package/dist/hosted/hosted-node-service.d.ts +7 -0
  14. package/dist/hosted/hosted-node-service.js +16 -4
  15. package/dist/hosted/hosted-node-service.js.map +1 -1
  16. package/dist/hosted/hosted-node-watcher.d.ts +16 -4
  17. package/dist/hosted/hosted-node-watcher.js +45 -9
  18. package/dist/hosted/hosted-node-watcher.js.map +1 -1
  19. package/dist/hosted/node-status.d.ts +19 -3
  20. package/dist/hosted/node-status.js +21 -5
  21. package/dist/hosted/node-status.js.map +1 -1
  22. package/dist/hosted/types.d.ts +12 -5
  23. package/dist/hosted/types.js.map +1 -1
  24. package/dist/installer/config.d.ts +23 -0
  25. package/dist/installer/config.js +46 -0
  26. package/dist/installer/config.js.map +1 -1
  27. package/dist/installer/index.d.ts +2 -0
  28. package/dist/installer/index.js +7 -16
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/wizard.d.ts +12 -2
  31. package/dist/installer/wizard.js +15 -7
  32. package/dist/installer/wizard.js.map +1 -1
  33. package/dist/nat/index.d.ts +2 -1
  34. package/dist/nat/index.js +1 -0
  35. package/dist/nat/index.js.map +1 -1
  36. package/dist/nat/nat-service.d.ts +22 -5
  37. package/dist/nat/nat-service.js +75 -26
  38. package/dist/nat/nat-service.js.map +1 -1
  39. package/dist/nat/nat-store.js +11 -0
  40. package/dist/nat/nat-store.js.map +1 -1
  41. package/dist/nat/network-mode.d.ts +19 -0
  42. package/dist/nat/network-mode.js +32 -0
  43. package/dist/nat/network-mode.js.map +1 -0
  44. package/dist/nat/reachability.d.ts +5 -4
  45. package/dist/nat/reachability.js +19 -6
  46. package/dist/nat/reachability.js.map +1 -1
  47. package/dist/nat/types.d.ts +26 -6
  48. package/dist/nat/types.js.map +1 -1
  49. package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
  50. package/dist/orchestrator/host-process-orchestrator.js +6 -15
  51. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  52. package/dist/package-version.d.ts +1 -0
  53. package/dist/package-version.js +4 -0
  54. package/dist/package-version.js.map +1 -0
  55. package/dist/server/routes/settings.d.ts +1 -0
  56. package/dist/server/routes/settings.js +15 -1
  57. package/dist/server/routes/settings.js.map +1 -1
  58. package/dist/server/routes/status.js +2 -32
  59. package/dist/server/routes/status.js.map +1 -1
  60. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  61. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  62. package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +4 -5
  65. package/src/__tests__/cli.smoke.test.ts +1 -1
  66. package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
  67. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  68. package/src/__tests__/orchestrator-handle-contract.ts +1 -1
  69. package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
  70. package/src/__tests__/orchestrator-push.test.ts +3 -3
  71. package/src/__tests__/orchestrator.test.ts +3 -5
  72. package/src/__tests__/ui-access.test.ts +30 -0
  73. package/src/bin/host.ts +30 -20
  74. package/src/bin/nat-output.ts +12 -1
  75. package/src/bin/ui-access.ts +50 -0
  76. package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
  77. package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
  78. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
  79. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
  80. package/src/hosted/claim-details.ts +44 -37
  81. package/src/hosted/hosted-node-service.ts +23 -4
  82. package/src/hosted/hosted-node-watcher.ts +288 -250
  83. package/src/hosted/node-status.ts +31 -5
  84. package/src/hosted/types.ts +160 -153
  85. package/src/installer/__tests__/installer.smoke.test.ts +184 -182
  86. package/src/installer/__tests__/wizard.test.ts +76 -56
  87. package/src/installer/config.ts +66 -0
  88. package/src/installer/index.ts +252 -259
  89. package/src/installer/wizard.ts +101 -87
  90. package/src/nat/__tests__/nat-service.test.ts +608 -540
  91. package/src/nat/__tests__/nat-store.test.ts +130 -127
  92. package/src/nat/__tests__/network-mode.test.ts +38 -0
  93. package/src/nat/index.ts +91 -87
  94. package/src/nat/nat-service.ts +79 -26
  95. package/src/nat/nat-store.ts +249 -237
  96. package/src/nat/network-mode.ts +36 -0
  97. package/src/nat/reachability.ts +155 -143
  98. package/src/nat/types.ts +232 -209
  99. package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
  100. package/src/package-version.ts +4 -0
  101. package/src/server/__tests__/fakes.ts +41 -40
  102. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
  103. package/src/server/__tests__/settings-route.test.ts +31 -0
  104. package/src/server/routes/settings.ts +128 -115
  105. package/src/server/routes/status.ts +72 -102
  106. package/dist/ui/assets/index-4FWuU8ye.js +0 -19
  107. package/dist/ui/assets/index-Di9Q4d01.css +0 -1
@@ -1,1098 +1,1089 @@
1
- import { spawn, type ChildProcess } from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import { randomBytes } from 'node:crypto';
4
- import {
5
- existsSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- mkdirSync,
10
- statSync,
11
- openSync,
12
- readSync,
13
- closeSync,
14
- } from 'node:fs';
15
- import { join, resolve as resolvePath, sep } from 'node:path';
16
- import debug from 'debug';
17
- import pidusage from 'pidusage';
18
- import {
19
- PortAllocator,
20
- type Orchestrator,
21
- type OrchestratorCreateRequest,
22
- type OrchestratorCreateResult,
23
- type OrchestratorStats,
24
- } from '@serfab/cadre-provider';
25
-
26
- import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
27
- import { ensureNodeIdentity } from './node-identity.js';
28
- import {
29
- allocateNodePorts,
30
- releaseNodePorts,
31
- reserveNodePorts,
32
- reusedNodePorts,
33
- } from './port-allocator.js';
34
- import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
35
- import { isPidAlive } from './pid-liveness.js';
36
- import { assertPortFree, type PortBinding } from './port-probe.js';
37
- import type { PushCredentials } from '@serfab/cadre-core';
38
- // The child's `cadre.json` is typed against the CLI's own config schema, so a key the CLI stops
39
- // accepting (or starts requiring) is a compile error here rather than a child that fails to start.
40
- import { strandFilterConfigFromText, type CliConfig } from '@serfab/cadre-cli';
41
- import {
42
- encodeDockerId,
43
- type Handle,
44
- type HostProcessConfig,
45
- type HostedSpawnRequest,
46
- type ManagedNodeInfo,
47
- type NodePorts,
48
- type NodeStateListener,
49
- type PushCredentialsResolver,
50
- } from './types.js';
51
-
52
- const log = debug('cadre:host:orchestrator');
53
-
54
- const DEFAULTS = {
55
- portRangeStart: 10000,
56
- portRangeEnd: 20000,
57
- stopTimeoutMs: 10000,
58
- logMaxBytes: 10 * 1024 * 1024,
59
- logMaxFiles: 5,
60
- };
61
-
62
- const STARTUP_TOKEN_FILE = '.startup-token';
63
- const CONFIG_FILE = 'cadre.json';
64
- const LIVENESS_POLL_MS = 100;
65
-
66
- const CADRE_ENV_PREFIX = 'CADRE_';
67
-
68
- /**
69
- * Manager's own `process.env` with every `CADRE_*` key removed. A child node
70
- * takes its whole configuration from the config file this orchestrator writes
71
- * plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
72
- * `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
73
- * would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
74
- * reconfigure every spawned child. Scrubbing the whole prefix rather than a
75
- * list of known keys means a new cli env var can't reintroduce the leak by
76
- * being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
77
- * still pass through untouched.
78
- */
79
- function scrubbedParentEnv(): NodeJS.ProcessEnv {
80
- const env = { ...process.env };
81
- for (const key of Object.keys(env)) {
82
- if (key.startsWith(CADRE_ENV_PREFIX)) delete env[key];
83
- }
84
- return env;
85
- }
86
-
87
- /**
88
- * The env carrying how the child gets into its cadre, and nowhere else (see
89
- * `HostedSpawnRequest`): `CADRE_CLAIM_SECRET` makes `cadre-cli start` wait to be
90
- * claimed, `CADRE_INVITATION` makes it redeem the invitation once it is up.
91
- */
92
- function joinEnv(request: HostedSpawnRequest): Record<string, string> | undefined {
93
- if (request.claimSecret) return { CADRE_CLAIM_SECRET: request.claimSecret };
94
- if (request.invitation) return { CADRE_INVITATION: request.invitation };
95
- return undefined;
96
- }
97
-
98
- /**
99
- * The libp2p listen addresses a managed child binds — its `CADRE_LISTEN_ADDRS`, one
100
- * entry per element: TCP on the `p2p` port and WebSocket on the `ws` port, both on
101
- * every interface.
102
- *
103
- * The WebSocket entry is what lets a phone reach the child. A phone's node carries
104
- * WebSocket, circuit-relay and WebRTC transports but no TCP
105
- * (`reference-app-rn/src/phone-node-config.ts`), and it listens on nothing, so the
106
- * phone has to be the one that dials. Naming a `/ws` address is all the child needs:
107
- * cadre-core adds the WebSocket transport for any listen entry that names one
108
- * (`resolveTransportOptions` in `cadre-core/src/relay-addrs.ts`).
109
- */
110
- export function childListenAddrs(ports: Pick<NodePorts, 'p2p' | 'ws'>): string[] {
111
- return [`/ip4/0.0.0.0/tcp/${ports.p2p}`, `/ip4/0.0.0.0/tcp/${ports.ws}/ws`];
112
- }
113
-
114
- /**
115
- * Every TCP port a managed child binds, on the address it binds it to: health and
116
- * metrics (`cadre-cli`'s `HealthServer`) and the libp2p TCP and WebSocket listeners
117
- * ({@link childListenAddrs}), all on every interface. A key the set lacks (a handle
118
- * persisted by an older build) is skipped.
119
- */
120
- function childPortBindings(ports: Partial<NodePorts>): PortBinding[] {
121
- const bindings: PortBinding[] = [];
122
- for (const port of [ports.health, ports.metrics, ports.p2p, ports.ws]) {
123
- if (port !== undefined) bindings.push({ port, host: '0.0.0.0' });
124
- }
125
- return bindings;
126
- }
127
-
128
- /**
129
- * Runs cadre nodes as plain Node child processes on the host.
130
- *
131
- * Each container gets its own workdir under `rootDir`. Handles are mirrored
132
- * to `<rootDir>/state.json` so cadre-host can re-attach after a restart.
133
- *
134
- * ## Child stdio: direct-fd inheritance
135
- *
136
- * Spawned children inherit a file descriptor opened on `<workdir>/node.log`
137
- * directly as their stdout and stderr. The orchestrator closes its own copy
138
- * of the fd immediately after spawn, so the child's write path no longer
139
- * touches any handle owned by this process. This is what lets children
140
- * survive — and keep logging through — an orchestrator restart. Were the
141
- * orchestrator instead piping stdout/stderr through an in-process Writable,
142
- * a parent exit would deliver SIGPIPE to the child (POSIX) or fail its
143
- * writes with EPIPE (Windows), silently breaking the documented "init()
144
- * re-attach" flow.
145
- *
146
- * ## Log rotation: spawn-time only
147
- *
148
- * Because the child holds a stable fd open against the inode, the
149
- * orchestrator cannot safely rename `node.log` while the child is running.
150
- * Rotation therefore happens only at spawn time, when no live child is
151
- * attached to the workdir (`createContainer` is the single producer per
152
- * workdir). If `node.log` already exceeds `logMaxBytes`, the rotation
153
- * cascade runs against the on-disk paths before the fresh fd is opened.
154
- * A child whose log grows past `logMaxBytes` mid-run will keep appending
155
- * to the active file until it (or its successor) is restarted; v1 traffic
156
- * is low enough that this is acceptable.
157
- */
158
- export class HostProcessOrchestrator implements Orchestrator {
159
- private readonly rootDir: string;
160
- private readonly portAllocator: PortAllocator;
161
- private readonly stateStore: StateStore;
162
- private readonly stopTimeoutMs: number;
163
- private readonly logMaxBytes: number;
164
- private readonly logMaxFiles: number;
165
- private readonly defaultMemoryLimit?: string;
166
- private readonly handles = new Map<string, Handle>();
167
- private readonly cliEntrypoint: string;
168
- /** Resolves push credentials fresh on every spawn (see HostProcessConfig). */
169
- private readonly pushResolver?: PushCredentialsResolver;
170
- /** The public addresses a node announces, asked at every spawn (see HostProcessConfig). */
171
- private readonly announceAddrs?: HostProcessConfig['announceAddrs'];
172
- /** State-change listeners — invoked when a handle's alive state changes. */
173
- private readonly stateListeners = new Set<NodeStateListener>();
174
-
175
- constructor(private readonly cfg: HostProcessConfig) {
176
- this.rootDir = resolvePath(cfg.rootDir);
177
- mkdirSync(this.rootDir, { recursive: true });
178
- this.portAllocator = new PortAllocator(
179
- cfg.portRange?.start ?? DEFAULTS.portRangeStart,
180
- cfg.portRange?.end ?? DEFAULTS.portRangeEnd,
181
- );
182
- this.stateStore = new StateStore(this.rootDir);
183
- this.stopTimeoutMs = cfg.stopTimeoutMs ?? DEFAULTS.stopTimeoutMs;
184
- this.logMaxBytes = cfg.logMaxBytes ?? DEFAULTS.logMaxBytes;
185
- this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
186
- this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
187
- this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
188
- this.pushResolver = cfg.pushResolver;
189
- this.announceAddrs = cfg.announceAddrs;
190
- log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
191
- }
192
-
193
- /**
194
- * Read persisted state and re-attach to surviving children. Not part of
195
- * `Orchestrator` — call once before handing the instance to a container
196
- * service.
197
- */
198
- async init(): Promise<void> {
199
- const state = this.stateStore.load();
200
- for (const persisted of state.handles) {
201
- const handle: Handle = {
202
- containerId: persisted.containerId,
203
- dockerId: persisted.dockerId,
204
- pid: persisted.pid,
205
- startupToken: persisted.startupToken,
206
- workdir: persisted.workdir,
207
- ports: persisted.ports,
208
- spawnedAt: persisted.spawnedAt,
209
- partyId: persisted.partyId,
210
- profile: persisted.profile,
211
- announcedAddrs: persisted.announcedAddrs ?? [],
212
- alive: false,
213
- };
214
- handle.alive = isHandleLive(handle);
215
- this.handles.set(handle.dockerId, handle);
216
- // Always reserve ports — even for dead handles, until the caller cleans them up
217
- this.reservePorts(persisted.ports);
218
- }
219
- log('init: re-attached %d handles', state.handles.length);
220
- }
221
-
222
- /**
223
- * Snapshot of currently-known managed nodes. Used by the local UI's
224
- * `/api/nodes` route.
225
- */
226
- listNodes(): ManagedNodeInfo[] {
227
- return [...this.handles.values()].map((h) => toNodeInfo(h));
228
- }
229
-
230
- /**
231
- * Look up a single managed node by either containerId (friendly id) or
232
- * dockerId (opaque `pid:token`). Returns undefined when neither matches.
233
- */
234
- getNode(idOrDockerId: string): ManagedNodeInfo | undefined {
235
- const direct = this.handles.get(idOrDockerId);
236
- if (direct) return toNodeInfo(direct);
237
- for (const h of this.handles.values()) {
238
- if (h.containerId === idOrDockerId) return toNodeInfo(h);
239
- }
240
- return undefined;
241
- }
242
-
243
- /**
244
- * Resolve the opaque dockerId for a friendly containerId so callers can
245
- * pass it into stopContainer / getLogs / getStats.
246
- */
247
- resolveDockerId(idOrDockerId: string): string | undefined {
248
- if (this.handles.has(idOrDockerId)) return idOrDockerId;
249
- for (const h of this.handles.values()) {
250
- if (h.containerId === idOrDockerId) return h.dockerId;
251
- }
252
- return undefined;
253
- }
254
-
255
- /**
256
- * Register a state-change listener. Invoked when a handle's `alive`
257
- * transitions (create, child exit, manual stop) and once more, as `stopped`,
258
- * when a handle is removed. Returns an unsubscribe fn.
259
- */
260
- onStateChange(listener: NodeStateListener): () => void {
261
- this.stateListeners.add(listener);
262
- return () => { this.stateListeners.delete(listener); };
263
- }
264
-
265
- /**
266
- * Spawn a managed node — a hosted node for some cadre, started waiting to be
267
- * claimed when the request carries a `claimSecret`, or redeeming the cadre
268
- * invitation it carries as `invitation`. The node is given its own
269
- * protobuf identity key inside its workdir (`ensureNodeIdentity`, reused across
270
- * re-spawns of the same containerId), which is what makes its peer id stable
271
- * across restarts AND what makes its node-local stores durable: `cadre-cli
272
- * start` opens the file-backed bootstrap-peer, trusted-owner and strand
273
- * network-state stores in the node's state directory, which defaults to the
274
- * directory holding the `cadre.json` written here — the same workdir.
275
- * Removing the node (`removeContainer`) deletes the workdir, so the key and
276
- * every store go with it.
277
- */
278
- async createContainer(request: HostedSpawnRequest): Promise<OrchestratorCreateResult> {
279
- // Before anything is allocated: `cadre-cli start` refuses the pair anyway, and a child
280
- // that exits at once would only surface as a crash loop.
281
- if (request.claimSecret && request.invitation) {
282
- throw new Error(`container ${request.containerId}: a spawn carries a claim secret or an invitation, not both`);
283
- }
284
- const workdir = this.workdirFor(request.containerId);
285
- // Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
286
- // existence as a side effect of writing the key. Only a spawn that found
287
- // nothing here may delete anything on its way out — a re-spawn is REUSING
288
- // the identity key and node-local stores this directory holds, and they are
289
- // the whole reason the node comes back as the same peer. `false` is
290
- // therefore the safe default: a directory that exists for any reason
291
- // (respawn, an earlier failed provision, an operator's file) is never
292
- // touched by the unwind below.
293
- const isNewWorkdir = !existsSync(workdir);
294
- let dropped: Handle[] = [];
295
- let ports: NodePorts | undefined;
296
- try {
297
- // Identity BEFORE any port is reserved: it is the one step here that can
298
- // fail on its own (an unreadable or undecodable identity.key throws rather
299
- // than silently re-keying), and everything past the allocation is either
300
- // infallible or releases the ports itself. Allocating first would leak a
301
- // node's worth of ports from a bounded range on every failed provision attempt.
302
- const identity = await ensureNodeIdentity(workdir);
303
- log('container %s identity peerId=%s', request.containerId, identity.peerId);
304
- // Only storage-profile nodes participate in strands and thus fan out push
305
- // wakes — a transaction-only node need not carry credentials. Resolved
306
- // BEFORE the drop below, which keeps the drop → launch window synchronous
307
- // (see restoreDroppedHandles).
308
- const push = request.profile === 'storage' ? await this.resolvePush() : undefined;
309
- const extraEnv = joinEnv(request);
310
-
311
- // The last `await` on this path: checked as close to the drop as it can be.
312
- await this.refuseRespawnOverLiveChild(request.containerId);
313
-
314
- // Nothing from here to the launch is `await`ed — which is what keeps
315
- // `restoreDroppedHandles`'s documented precondition true even though the
316
- // `try` now opens further up. Do not add an `await` past this line.
317
- dropped = this.dropStaleHandle(request.containerId);
318
- // A re-spawn comes back on the ports the dropped handle held, not merely the
319
- // lowest free ones: a requester that dials the node itself (a phone) knows it
320
- // only by the address it was given. The drop above just released them, and
321
- // nothing can have taken them since — see reusedNodePorts.
322
- ports = allocateNodePorts(this.portAllocator, reusedNodePorts(dropped));
323
- return this.launchChild({
324
- containerId: request.containerId,
325
- partyId: request.partyId,
326
- profile: request.profile,
327
- ports,
328
- buildConfig: () => this.buildChildConfig(request, workdir, push),
329
- extraArgs: ['--identity-file', identity.path],
330
- ...(extraEnv ? { extraEnv } : {}),
331
- ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
332
- });
333
- } catch (err) {
334
- // Release BEFORE restoring: a re-spawn allocates the very ports the drop
335
- // just freed, so restoring first and releasing second would hand the
336
- // restored handle's own ports straight back to the allocator.
337
- if (ports) this.releasePorts(ports);
338
- // A no-op for the failures that happen before the drop (`dropped` is
339
- // still empty).
340
- this.restoreDroppedHandles(dropped);
341
- // Safe because a throw out of `launchChild` means NO child is running:
342
- // its throw surface ends where the child is spawned and its handle stored
343
- // (everything past that line is best-effort), and the `if (!child.pid)`
344
- // throw is on the no-process side too. So nothing is ever holding the
345
- // directory this deletes.
346
- if (isNewWorkdir) this.discardWorkdir(workdir);
347
- throw err;
348
- }
349
- }
350
-
351
- /**
352
- * Remove the working directory a spawn created for `containerId` when no
353
- * handle owns it — the cleanup of last resort for a hosted-node record that
354
- * never got a `dockerId` (the host died between `ensureNodeIdentity` and the
355
- * handle write, so `createContainer`'s own unwind never ran). Returns whether
356
- * anything was removed.
357
- *
358
- * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
359
- * to await.
360
- */
361
- reclaimWorkdir(containerId: string): boolean {
362
- // A live handle owns that directory, and `removeContainer` — which stops
363
- // the child first — is the only thing allowed to delete it.
364
- if (this.resolveDockerId(containerId)) return false;
365
- const workdir = this.workdirFor(containerId);
366
- // Defence, not a live case: hosted-node ids are `hn_<base64url>` and so can
367
- // hold neither a path separator nor a dot. A caller that ever passes
368
- // something else must not be able to walk out of `rootDir`.
369
- if (!workdir.startsWith(this.rootDir + sep)) {
370
- log('refusing to reclaim workdir outside rootDir: %s', workdir);
371
- return false;
372
- }
373
- // Must be a DIRECTORY, not merely something that exists: `rootDir` also
374
- // holds `state.json` (this orchestrator's own persisted handles), and
375
- // `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
376
- // defence class as the escape check above — no real containerId names a
377
- // file — but the blast radius if one ever did is the whole handle map.
378
- if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory()) return false;
379
- this.discardWorkdir(workdir);
380
- return true;
381
- }
382
-
383
- /**
384
- * Best-effort removal of a workdir no handle owns. Logs, never throws: every
385
- * caller is either unwinding a failure it must report instead of this one, or
386
- * tearing a container down along a path where a surviving directory is a
387
- * nuisance rather than a fault.
388
- *
389
- * `retries` sizes the wait for the OS to release a cwd handle — Windows drops
390
- * it asynchronously after a child exits. `createContainer`'s unwind needs none
391
- * of that (its throw surface ends before any child is spawned), but
392
- * `reclaimWorkdir` can still meet an orphan process the previous host left
393
- * running with this directory as its cwd, so it is not a *guaranteed*
394
- * no-process path either — it simply logs and leaves the directory when the
395
- * removal fails.
396
- */
397
- private discardWorkdir(workdir: string, retries = { maxRetries: 5, retryDelay: 100 }): void {
398
- try {
399
- rmSync(workdir, { recursive: true, force: true, ...retries });
400
- log('discarded workdir %s', workdir);
401
- } catch (err) {
402
- log('failed to discard workdir %s: %o', workdir, err);
403
- }
404
- }
405
-
406
- /**
407
- * Resolve the push credentials to inject into the node about to spawn. Called
408
- * on every spawn so a re-spawn re-reads the secret store — raw private keys are
409
- * never persisted. A resolver failure (e.g. a partial credential set) is
410
- * logged and degrades to "no push" rather than failing the spawn: the node
411
- * stays up and reachable, just without platform push-wake.
412
- */
413
- private async resolvePush(): Promise<PushCredentials | undefined> {
414
- if (!this.pushResolver) return undefined;
415
- try {
416
- const push = await this.pushResolver();
417
- if (push) {
418
- log('resolved push credentials: fcm=%s apns=%s cooldownMs=%s', !!push.fcm, !!push.apns, push.cooldownMs);
419
- }
420
- return push;
421
- } catch (err) {
422
- log('push resolver failed; spawning node without push: %s', (err as Error).message);
423
- return undefined;
424
- }
425
- }
426
-
427
- /**
428
- * The public addresses the node about to spawn announces. A hook that throws costs
429
- * the node its public addresses, never its start: it still binds and is reachable
430
- * on its LAN addresses, and `NatService` restarts it once the addresses it should
431
- * announce differ from these.
432
- */
433
- private announceAddrsFor(containerId: string, ports: NodePorts): string[] {
434
- if (!this.announceAddrs) return [];
435
- try {
436
- return this.announceAddrs(containerId, ports);
437
- } catch (err) {
438
- log('announce hook failed for %s; it starts with no public addresses: %s', containerId, (err as Error).message);
439
- return [];
440
- }
441
- }
442
-
443
- /**
444
- * Core spawn mechanics behind `createContainer`.
445
- *
446
- * The caller has already allocated `ports` and **owns their lifetime**: this
447
- * method never releases them, so the caller must release on any throw out
448
- * of here (it has to unwind its handle drop on the same path anyway — see
449
- * `restoreDroppedHandles`).
450
- *
451
- * That contract holds because this method throws only *before* the child is
452
- * spawned and its handle stored — everything after that point is
453
- * best-effort. Anything new added at the tail must keep it that way.
454
- *
455
- * Entirely synchronous, which is what makes that unwind sound. Do not add an
456
- * `await` here.
457
- */
458
- private launchChild(opts: {
459
- containerId: string;
460
- partyId: string;
461
- profile: 'storage' | 'transaction';
462
- ports: NodePorts;
463
- buildConfig: (workdir: string) => CliConfig;
464
- extraArgs: string[];
465
- /** Extra env vars merged into the child's environment (e.g. CADRE_CLAIM_SECRET). */
466
- extraEnv?: Record<string, string>;
467
- memoryLimit?: string;
468
- }): OrchestratorCreateResult {
469
- const { containerId, ports } = opts;
470
- const workdir = this.workdirFor(containerId);
471
- mkdirSync(workdir, { recursive: true });
472
- mkdirSync(join(workdir, 'storage'), { recursive: true });
473
-
474
- const token = randomBytes(16).toString('hex');
475
- // Per-container secret gating the node's `POST /seed` route. The node
476
- // refuses seed delivery unless the caller presents this as a bearer token
477
- // (and unregisters the route entirely when the env var is empty), so we
478
- // mint a fresh high-entropy value per child and hand it back on the result
479
- // for `ContainerService.applySeed` to send.
480
- const seedToken = randomBytes(32).toString('base64url');
481
- const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
482
- // Ensure stale token from a previous container with the same id is cleared.
483
- if (existsSync(tokenPath)) {
484
- try { rmSync(tokenPath); } catch { /* ignore */ }
485
- }
486
-
487
- const configPath = join(workdir, CONFIG_FILE);
488
- writeFileSync(configPath, JSON.stringify(opts.buildConfig(workdir), null, 2), 'utf8');
489
-
490
- const memoryLimit = opts.memoryLimit ?? this.defaultMemoryLimit;
491
- const heapBytes = parseMemoryLimit(memoryLimit);
492
- const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
493
- const announcedAddrs = this.announceAddrsFor(containerId, ports);
494
-
495
- const env: NodeJS.ProcessEnv = {
496
- // Scrubbed, not raw process.env: every CADRE_* config-override key is
497
- // stripped so a var set on the manager can't silently reconfigure every
498
- // child (see scrubbedParentEnv). Caller-supplied extras come next — the
499
- // fixed per-child vars below always win so extraEnv can never clobber
500
- // the seed/startup tokens or port wiring.
501
- ...scrubbedParentEnv(),
502
- ...opts.extraEnv,
503
- CADRE_STARTUP_TOKEN: token,
504
- CADRE_HEALTH_PORT: String(ports.health),
505
- CADRE_METRICS_PORT: String(ports.metrics),
506
- CADRE_LISTEN_ADDRS: childListenAddrs(ports).join(','),
507
- // The append form, not `CADRE_ANNOUNCE_ADDRS`: that one replaces the advertised
508
- // set and would drop the LAN addresses a phone at home dials. Every entry must be
509
- // a valid multiaddr: cadre-core refuses to start on one that is not.
510
- // The TCP and WebSocket ports are the CHILD'S CONTROL NODE's alone: each strand
511
- // node the child runs binds the same two entries with OS-assigned ports instead,
512
- // since one port cannot be held twice, and inherits neither announce field
513
- // (`cadre-core/src/strand-network-config.ts`). Those ports are not mapped, and
514
- // strand nodes are reached through observed addresses only, since no
515
- // `CADRE_RELAY_ADDRS` is set here and a child therefore holds no relay reservation.
516
- ...(announcedAddrs.length > 0 ? { CADRE_APPEND_ANNOUNCE_ADDRS: announcedAddrs.join(',') } : {}),
517
- CADRE_SEED_TOKEN: seedToken,
518
- // Pin each child's node-local state (trusted-owner anchor, retained
519
- // cold-start dial targets) to its OWN workdir. This is the same value the
520
- // cli would derive by default (the config file lives here too), but stated
521
- // explicitly rather than relying on the scrub above (belt and suspenders).
522
- CADRE_NODE_STATE_DIR: workdir,
523
- };
524
- if (heapMB !== undefined) {
525
- env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
526
- }
527
-
528
- // Rotate the on-disk log files BEFORE opening the fd — once the child
529
- // is running it holds the inode open and any later rename would leave
530
- // it writing to the rotated file. See class docstring.
531
- const logPath = defaultLogPath(workdir);
532
- maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
533
- const logFd = openSync(logPath, 'a');
534
-
535
- const args = [
536
- this.cliEntrypoint,
537
- 'start',
538
- '-c', configPath,
539
- '--health-port', String(ports.health),
540
- '--metrics-port', String(ports.metrics),
541
- '--startup-token-file', tokenPath,
542
- ...opts.extraArgs,
543
- ];
544
-
545
- let child: ChildProcess;
546
- try {
547
- child = spawn(process.execPath, args, {
548
- cwd: workdir,
549
- detached: true,
550
- // Inherit the log fd directly. OS duplicates it into the child;
551
- // closing our copy below is safe and severs the parent's only
552
- // tie to the child's write path.
553
- stdio: ['ignore', logFd, logFd],
554
- env,
555
- });
556
- } catch (err) {
557
- // Synchronous spawn failure — close the fd before propagating. This is
558
- // the ONLY fd-leak guard on this path: nothing between the `openSync`
559
- // above and the `spawn` can throw, so no other catch is needed. Keep it
560
- // that way, or add one. (The ports are the caller's to release.)
561
- closeSync(logFd);
562
- throw err;
563
- }
564
- // Always release the parent's copy of the fd — the child has its own.
565
- closeSync(logFd);
566
-
567
- if (!child.pid) {
568
- throw new Error(`Failed to spawn child for container ${containerId}`);
569
- }
570
-
571
- child.unref();
572
-
573
- const dockerId = encodeDockerId(child.pid, token);
574
- const handle: Handle = {
575
- containerId,
576
- dockerId,
577
- pid: child.pid,
578
- startupToken: token,
579
- workdir,
580
- ports,
581
- spawnedAt: new Date().toISOString(),
582
- partyId: opts.partyId,
583
- profile: opts.profile,
584
- announcedAddrs,
585
- child,
586
- alive: true,
587
- };
588
-
589
- child.once('exit', () => {
590
- const h = this.handles.get(dockerId);
591
- if (h && h.alive) {
592
- h.alive = false;
593
- this.emitStateChange(h);
594
- }
595
- });
596
-
597
- this.handles.set(dockerId, handle);
598
- // Past this line the launch has SUCCEEDED: the child is running and this
599
- // handle owns its ports. So the state mirror is best-effort — a throw here
600
- // would reach the caller's unwind, which would release ports the live child
601
- // is bound to and restore the handle this spawn just replaced, leaving two
602
- // handles for one container and one of them holding unreserved ports. A
603
- // lost write self-heals: the next persist() (any stop/remove/spawn)
604
- // rewrites the whole map. Until then a manager restart would fail to
605
- // re-attach this child, hence the log.
606
- try {
607
- this.persist();
608
- } catch (err) {
609
- log('failed to persist state after launching container %s: %o', containerId, err);
610
- }
611
- this.emitStateChange(handle);
612
-
613
- log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
614
-
615
- return {
616
- dockerId,
617
- healthEndpoint: `http://localhost:${ports.health}/health`,
618
- metricsEndpoint: `http://localhost:${ports.metrics}/metrics`,
619
- // The node's seed API (`POST /seed`) is bound to the same server/port as `/health`.
620
- seedEndpoint: `http://localhost:${ports.health}/seed`,
621
- seedToken,
622
- p2pPort: ports.p2p,
623
- };
624
- }
625
-
626
- /**
627
- * Refuse to re-spawn `containerId` while a previous child of it may still be
628
- * running. `createContainer` calls this as its last `await` before the handle
629
- * drop, so a refusal happens while nothing has been released, no token rotated
630
- * and no child launched — the caller sees an ordinary failed spawn.
631
- *
632
- * Why it exists: the drop hands the old handle's ports back and the launch takes
633
- * those same ports (`reusedNodePorts`), so a child started over a live one dies
634
- * on a port clash — but only after `launchChild` has returned success with a
635
- * fresh seed token. The caller then records a dead child, and the live one
636
- * refuses the new token.
637
- *
638
- * - A handle this process spawned is checked exactly ({@link isHandleLive}); a
639
- * live one refuses outright.
640
- * - A handle re-attached from `state.json` whose pid is alive but whose token
641
- * does not match is either our child still starting or an unrelated process
642
- * that inherited the pid. The ports decide: if any the re-spawn would take back
643
- * is already bound, refuse. The pid is never killed on that evidence — it may
644
- * not be ours.
645
- *
646
- * NOTE: the probe cannot see a re-attached child still loading its modules —
647
- * `cadre-cli start` writes its token and only then binds, so a child in that
648
- * window shows neither, and a re-spawn launched then races it for the ports.
649
- * Reachable only when the host restarts within a child's first seconds. If it
650
- * is ever seen, treat a re-attached handle with a live pid as starting for a
651
- * grace period after its `spawnedAt`.
652
- *
653
- * NOTE: the probe holds each port for an instant, so a child binding that port
654
- * in the same instant fails. It would have had to write its token between the
655
- * token check above and the probe, against the several awaited steps
656
- * `cadre-cli start` takes between writing its token and binding; if a re-attached
657
- * child is ever seen dying on its own port right after a host restart, probe by
658
- * connecting instead of binding.
659
- */
660
- private async refuseRespawnOverLiveChild(containerId: string): Promise<void> {
661
- const previous = this.handlesFor(containerId);
662
- if (previous.some(isHandleLive)) {
663
- throw new Error(`container ${containerId} is still running`);
664
- }
665
- if (!previous.some((h) => isPidAlive(h.pid))) return;
666
- const bindings = childPortBindings(reusedNodePorts(previous));
667
- try {
668
- await Promise.all(bindings.map(assertPortFree));
669
- } catch (err) {
670
- throw new Error(
671
- `container ${containerId} may still be running: ${(err as Error).message}`,
672
- { cause: err },
673
- );
674
- }
675
- }
676
-
677
- /** Every handle for `containerId`, in map order — the order `dropStaleHandle` drops them in. */
678
- private handlesFor(containerId: string): Handle[] {
679
- return [...this.handles.values()].filter((h) => h.containerId === containerId);
680
- }
681
-
682
- /**
683
- * Drop any handle left over from a previous spawn of the same `containerId`
684
- * and release its ports. Handles are keyed by the per-spawn `dockerId`, so
685
- * without this a re-spawn (a hosted node's respawn) would strand the prior
686
- * handle in the map forever, leaking its ports from a bounded range each
687
- * time.
688
- *
689
- * DO NOT delete the workdir: the identity key and node-local stores
690
- * (trusted-owner anchor, retained cold-start dial targets) live there and are
691
- * the whole reason a respawn comes back as the same node. `launchChild`
692
- * reuses the same workdir.
693
- *
694
- * Releasing the ports hands them straight back to the allocator, and the
695
- * re-spawn then takes those same ports as overrides (`reusedNodePorts`), so a
696
- * re-spawn while the *previous* child is still listening would bind-clash.
697
- * The caller must therefore never drop a live child, and does so only after
698
- * {@link refuseRespawnOverLiveChild} has passed. A caller that needs to replace
699
- * a live child must stop it first.
700
- *
701
- * **The drop is reversible.** It happens before the launch can fail, so the
702
- * dropped handles are returned and the caller restores them via
703
- * {@link restoreDroppedHandles} when the launch throws — otherwise the caller
704
- * would be left holding a dockerId this orchestrator no longer knows, and the
705
- * node's workdir could never be reclaimed. On a *successful* spawn the drop
706
- * stands: `HostedNodeService.abandonRespawn` depends on the old handle being
707
- * gone by then.
708
- */
709
- private dropStaleHandle(containerId: string): Handle[] {
710
- const dropped = this.handlesFor(containerId);
711
- for (const h of dropped) {
712
- log('dropping stale handle %s for container %s', h.dockerId, containerId);
713
- this.releasePorts(h.ports);
714
- this.handles.delete(h.dockerId);
715
- }
716
- return dropped;
717
- }
718
-
719
- /**
720
- * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
721
- * handle back in the map and re-reserve its ports, leaving host state
722
- * exactly as the failed attempt found it.
723
- *
724
- * **Precondition: the drop → launch window is synchronous.** Restoring is
725
- * sound only because nothing can have taken those ports or run cleanup
726
- * against the handle in between — `launchChild` contains no `await` and the
727
- * caller resolves push credentials *before* its drop. `launchChild`'s half
728
- * is enforced: its declared return type is not a Promise, so adding an
729
- * `await` there is a compile error. The caller's half is not — an `await`
730
- * slipped between a drop and its launch silently breaks this.
731
- *
732
- * No `persist()` here: `state.json` is rewritten only by `launchChild` and
733
- * only *after* the new handle is stored, so throughout the failure window the
734
- * on-disk state still lists the dropped handle — this restore realigns memory
735
- * to disk rather than diverging from it.
736
- *
737
- * NOTE: a restored handle keeps `alive: true` even if its child exited while
738
- * it was out of the map (`launchChild`'s exit listener looks the handle up by
739
- * dockerId and finds nothing). Only reachable by dropping a handle whose child
740
- * is still live, which {@link refuseRespawnOverLiveChild} refuses before any
741
- * drop. (Its start-up blind spot concerns re-attached handles only, and those
742
- * have no exit listener to miss.)
743
- */
744
- private restoreDroppedHandles(dropped: Handle[]): void {
745
- for (const h of dropped) {
746
- log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
747
- this.handles.set(h.dockerId, h);
748
- this.reservePorts(h.ports);
749
- }
750
- }
751
-
752
- /** A managed node's own directory under `rootDir` — config, log, storage, identity. */
753
- private workdirFor(containerId: string): string {
754
- return join(this.rootDir, containerId);
755
- }
756
-
757
- /**
758
- * Inverse of {@link releasePorts} — hold a handle's whole port set against
759
- * the allocator. Used both to rehydrate on `init` and to undo a drop whose
760
- * launch then failed.
761
- */
762
- private reservePorts(ports: NodePorts): void {
763
- reserveNodePorts(this.portAllocator, ports);
764
- }
765
-
766
- private releasePorts(ports: NodePorts): void {
767
- releaseNodePorts(this.portAllocator, ports);
768
- }
769
-
770
- async stopContainer(dockerId: string): Promise<void> {
771
- const handle = this.requireHandle(dockerId);
772
- if (!isPidAlive(handle.pid)) {
773
- handle.alive = false;
774
- this.persist();
775
- return;
776
- }
777
-
778
- // Capture the exit event before we send signals so we can await full
779
- // teardown — important on Windows where cwd handles release lazily.
780
- const exitPromise = handle.child
781
- ? new Promise<void>((resolve) => handle.child!.once('exit', () => resolve()))
782
- : Promise.resolve();
783
-
784
- log('SIGTERM container %s (pid=%d)', handle.containerId, handle.pid);
785
- try {
786
- process.kill(handle.pid, 'SIGTERM');
787
- } catch (err) {
788
- const code = (err as NodeJS.ErrnoException).code;
789
- if (code !== 'ESRCH') {
790
- throw err;
791
- }
792
- }
793
-
794
- const deadline = Date.now() + this.stopTimeoutMs;
795
- while (Date.now() < deadline) {
796
- if (!isPidAlive(handle.pid)) break;
797
- await sleep(LIVENESS_POLL_MS);
798
- }
799
-
800
- if (isPidAlive(handle.pid)) {
801
- log('SIGKILL container %s (pid=%d)', handle.containerId, handle.pid);
802
- try {
803
- process.kill(handle.pid, 'SIGKILL');
804
- } catch (err) {
805
- const code = (err as NodeJS.ErrnoException).code;
806
- if (code !== 'ESRCH') {
807
- throw err;
808
- }
809
- }
810
- const killDeadline = Date.now() + 2000;
811
- while (Date.now() < killDeadline && isPidAlive(handle.pid)) {
812
- await sleep(LIVENESS_POLL_MS);
813
- }
814
- }
815
-
816
- // Wait briefly for the OS to fully tear down the child (Windows cwd lock).
817
- await Promise.race([exitPromise, sleep(1000)]);
818
-
819
- const wasAlive = handle.alive;
820
- handle.alive = false;
821
- this.persist();
822
- if (wasAlive) this.emitStateChange(handle);
823
- }
824
-
825
- async removeContainer(dockerId: string): Promise<void> {
826
- const handle = this.requireHandle(dockerId);
827
- if (isPidAlive(handle.pid)) {
828
- await this.stopContainer(dockerId);
829
- }
830
-
831
- this.releasePorts(handle.ports);
832
-
833
- // Longer retries than the other callers': this is the one path that has
834
- // just killed a child which held this directory as its cwd, and on Windows
835
- // the OS releases that handle asynchronously.
836
- this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
837
-
838
- this.handles.delete(dockerId);
839
- this.persist();
840
- // Removal is a state change too: `NatService` releases the node's port
841
- // mappings on it at once rather than at its next timer pass. A stopped
842
- // event for a node no longer in `listNodes()` is how listeners tell a
843
- // removal from a stop.
844
- this.emitStateChange(handle);
845
- }
846
-
847
- /**
848
- * Whether the child `dockerId` names is running — see {@link isHandleLive}. An
849
- * id with no handle is not running: without the handle's workdir there is no
850
- * token to verify a live pid against.
851
- */
852
- async isRunning(dockerId: string): Promise<boolean> {
853
- const handle = this.handles.get(dockerId);
854
- if (!handle) return false;
855
- if (isHandleLive(handle)) return true;
856
- if (!isPidAlive(handle.pid)) handle.alive = false;
857
- return false;
858
- }
859
-
860
- async getStats(dockerId: string): Promise<OrchestratorStats> {
861
- const handle = this.requireHandle(dockerId);
862
- if (!isPidAlive(handle.pid)) {
863
- return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
864
- }
865
- try {
866
- const usage = await pidusage(handle.pid);
867
- return {
868
- cpuPercent: usage.cpu,
869
- memoryBytes: usage.memory,
870
- networkRxBytes: 0,
871
- networkTxBytes: 0,
872
- };
873
- } catch (err) {
874
- log('pidusage failed for %d: %o', handle.pid, err);
875
- return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
876
- }
877
- }
878
-
879
- async getLogs(dockerId: string, tail = 100): Promise<string> {
880
- const handle = this.requireHandle(dockerId);
881
- const logPath = defaultLogPath(handle.workdir);
882
- return tailFile(logPath, tail);
883
- }
884
-
885
- private requireHandle(dockerId: string): Handle {
886
- const handle = this.handles.get(dockerId);
887
- if (!handle) {
888
- throw new Error(`Container not found: ${dockerId}`);
889
- }
890
- return handle;
891
- }
892
-
893
- private buildChildConfig(
894
- req: OrchestratorCreateRequest,
895
- workdir: string,
896
- push?: PushCredentials,
897
- ): CliConfig {
898
- const cfg: CliConfig = {
899
- controlNetwork: {
900
- partyId: req.partyId,
901
- bootstrapNodes: req.bootstrapNodes,
902
- },
903
- profile: req.profile,
904
- // The request carries the filter as text (the provider hands the same text to
905
- // CADRE_STRAND_FILTER); the file form is `all`/`none` or the parsed JSON object, and a
906
- // filter the node would refuse fails here, before anything is spawned.
907
- // NOTE: an empty string is refused here, where cadre-provider's container-env treats it as
908
- // unset. Nothing in cadre-host populates `req.strandFilter` today; if the provision route
909
- // starts accepting one, pick one rule for both.
910
- strandFilter: req.strandFilter === undefined ? 'all' : strandFilterConfigFromText(req.strandFilter),
911
- };
912
- if (req.profile === 'storage') {
913
- cfg.storage = {
914
- type: 'file',
915
- path: join(workdir, 'storage'),
916
- ...(req.resources?.storageQuotaBytes !== undefined
917
- ? { quotaBytes: req.resources.storageQuotaBytes }
918
- : {}),
919
- };
920
- }
921
- // Push credentials land in the child's cadre.json on the host filesystem —
922
- // the same trust boundary as the control-DB storage in this workdir. The
923
- // private keys are NOT logged (the resolver redacts; we log only presence).
924
- if (push) cfg.push = push;
925
- return cfg;
926
- }
927
-
928
- private persist(): void {
929
- const state: PersistedState = {
930
- version: 1,
931
- handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
932
- };
933
- this.stateStore.save(state);
934
- }
935
-
936
- private emitStateChange(handle: Handle): void {
937
- if (this.stateListeners.size === 0) return;
938
- const info = toNodeInfo(handle);
939
- for (const listener of this.stateListeners) {
940
- try {
941
- listener(info);
942
- } catch (err) {
943
- log('state listener threw for %s: %o', info.id, err);
944
- }
945
- }
946
- }
947
-
948
- private toPersisted(h: Handle): PersistedHandle {
949
- return {
950
- containerId: h.containerId,
951
- dockerId: h.dockerId,
952
- pid: h.pid,
953
- startupToken: h.startupToken,
954
- workdir: h.workdir,
955
- ports: h.ports,
956
- spawnedAt: h.spawnedAt,
957
- partyId: h.partyId,
958
- profile: h.profile,
959
- announcedAddrs: h.announcedAddrs,
960
- };
961
- }
962
- }
963
-
964
- function toNodeInfo(h: Handle): ManagedNodeInfo {
965
- return {
966
- id: h.containerId,
967
- dockerId: h.dockerId,
968
- partyId: h.partyId,
969
- profile: h.profile,
970
- status: h.alive ? 'running' : 'stopped',
971
- spawnedAt: h.spawnedAt,
972
- workdir: h.workdir,
973
- ports: { ...h.ports },
974
- announcedAddrs: [...h.announcedAddrs],
975
- };
976
- }
977
-
978
- /**
979
- * Locate the cadre-cli bin via the consumer-style module resolver. Works for
980
- * both yarn-managed workspaces and traditional npm installs.
981
- */
982
- function resolveCadreCliBin(): string {
983
- const req = createRequire(import.meta.url);
984
- try {
985
- return req.resolve('@serfab/cadre-cli/bin/cadre.js');
986
- } catch (err) {
987
- throw new Error(
988
- `Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
989
- `Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
990
- `and listed as a dependency. Underlying error: ${(err as Error).message}`,
991
- { cause: err },
992
- );
993
- }
994
- }
995
-
996
- /**
997
- * Whether the process a handle names is still the child it was launched as — the
998
- * one liveness rule behind `init`, `isRunning` and the pre-spawn guard, so they
999
- * cannot drift apart.
1000
- *
1001
- * A handle this process spawned carries its `ChildProcess`, and that answer is
1002
- * exact from the moment of spawn: no exit seen, and the pid still answering. (The
1003
- * pid check covers the moment between the OS ending the process and Node
1004
- * delivering its exit; the pid cannot have been reused in that moment, since the
1005
- * OS holds it until Node has reaped the child.) The startup-token file is no use
1006
- * here — a child writes it only once it is running its own code, so reading it
1007
- * would report every child dead for its first moments.
1008
- *
1009
- * A handle re-attached from `state.json` after a host restart has no
1010
- * `ChildProcess`, so it falls back to the pid plus that token file, which tells our
1011
- * child apart from an unrelated process that inherited its pid.
1012
- */
1013
- function isHandleLive(handle: Handle): boolean {
1014
- const { child } = handle;
1015
- if (child) {
1016
- return child.exitCode === null && child.signalCode === null && isPidAlive(handle.pid);
1017
- }
1018
- return isPidAlive(handle.pid) && tokenMatches(handle.workdir, handle.startupToken);
1019
- }
1020
-
1021
- function tokenMatches(workdir: string, expected: string): boolean {
1022
- const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
1023
- try {
1024
- const contents = readFileSync(tokenPath, 'utf8').trim();
1025
- return contents === expected;
1026
- } catch {
1027
- return false;
1028
- }
1029
- }
1030
-
1031
- /**
1032
- * Rotate the on-disk log files for a workdir if the active file is already
1033
- * at or above the byte threshold. Called from `createContainer` before the
1034
- * child's stdio fd is opened — the only safe rotation point because the
1035
- * caller guarantees no live child is attached to this workdir.
1036
- */
1037
- function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
1038
- if (maxBytes <= 0 || maxFiles < 1) return;
1039
- let size: number;
1040
- try {
1041
- size = statSync(logPath).size;
1042
- } catch {
1043
- return;
1044
- }
1045
- if (size >= maxBytes) {
1046
- rotateOnDisk(logPath, maxFiles);
1047
- }
1048
- }
1049
-
1050
- function sleep(ms: number): Promise<void> {
1051
- return new Promise((res) => {
1052
- const t = setTimeout(res, ms);
1053
- if (typeof t.unref === 'function') t.unref();
1054
- });
1055
- }
1056
-
1057
- /**
1058
- * Read the last `n` lines from a log file. Reads from the tail in chunks
1059
- * rather than slurping the whole file.
1060
- */
1061
- function tailFile(path: string, n: number): string {
1062
- if (!existsSync(path)) return '';
1063
- const fd = openSync(path, 'r');
1064
- try {
1065
- const size = statSync(path).size;
1066
- const chunkSize = 8192;
1067
- let pos = size;
1068
- const buffers: Buffer[] = [];
1069
- let newlineCount = 0;
1070
- while (pos > 0 && newlineCount <= n) {
1071
- const readLen = Math.min(chunkSize, pos);
1072
- pos -= readLen;
1073
- const buf = Buffer.alloc(readLen);
1074
- readSync(fd, buf, 0, readLen, pos);
1075
- buffers.unshift(buf);
1076
- for (let i = 0; i < buf.length; i++) {
1077
- if (buf[i] === 0x0a) newlineCount++;
1078
- }
1079
- }
1080
- const joined = Buffer.concat(buffers).toString('utf8');
1081
- const lines = joined.split('\n');
1082
- // Drop any trailing empty line from a terminating newline before counting.
1083
- const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
1084
- return trimmed.slice(-n).join('\n');
1085
- } finally {
1086
- closeSync(fd);
1087
- }
1088
- }
1089
-
1090
- function parseMemoryLimit(limit?: string): number | undefined {
1091
- if (!limit) return undefined;
1092
- const match = limit.match(/^(\d+(?:\.\d+)?)\s*(B|K|M|G|T)?$/i);
1093
- if (!match) return undefined;
1094
- const [, num, unit] = match;
1095
- const multipliers: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
1096
- return Math.floor(parseFloat(num!) * (multipliers[unit?.toUpperCase() ?? 'B'] ?? 1));
1097
- }
1098
-
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { randomBytes } from 'node:crypto';
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ mkdirSync,
10
+ statSync,
11
+ openSync,
12
+ readSync,
13
+ closeSync,
14
+ } from 'node:fs';
15
+ import { join, resolve as resolvePath, sep } from 'node:path';
16
+ import debug from 'debug';
17
+ import pidusage from 'pidusage';
18
+ import {
19
+ PortAllocator,
20
+ type Orchestrator,
21
+ type OrchestratorCreateRequest,
22
+ type OrchestratorCreateResult,
23
+ type OrchestratorStats,
24
+ } from '@serfab/cadre-provider';
25
+
26
+ import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
27
+ import { ensureNodeIdentity } from './node-identity.js';
28
+ import {
29
+ allocateNodePorts,
30
+ releaseNodePorts,
31
+ reserveNodePorts,
32
+ reusedNodePorts,
33
+ } from './port-allocator.js';
34
+ import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
35
+ import { isPidAlive } from './pid-liveness.js';
36
+ import { assertPortFree, type PortBinding } from './port-probe.js';
37
+ import type { PushCredentials } from '@serfab/cadre-core';
38
+ // The child's `cadre.json` is typed against the CLI's own config schema, so a key the CLI stops
39
+ // accepting (or starts requiring) is a compile error here rather than a child that fails to start.
40
+ import { strandFilterConfigFromText, type CliConfig } from '@serfab/cadre-cli';
41
+ import {
42
+ encodeDockerId,
43
+ type Handle,
44
+ type HostProcessConfig,
45
+ type HostedSpawnRequest,
46
+ type ManagedNodeInfo,
47
+ type NodePorts,
48
+ type NodeStateListener,
49
+ type PushCredentialsResolver,
50
+ } from './types.js';
51
+
52
+ const log = debug('cadre:host:orchestrator');
53
+
54
+ const DEFAULTS = {
55
+ portRangeStart: 10000,
56
+ portRangeEnd: 20000,
57
+ stopTimeoutMs: 10000,
58
+ logMaxBytes: 10 * 1024 * 1024,
59
+ logMaxFiles: 5,
60
+ };
61
+
62
+ const STARTUP_TOKEN_FILE = '.startup-token';
63
+ const CONFIG_FILE = 'cadre.json';
64
+ const LIVENESS_POLL_MS = 100;
65
+
66
+ const CADRE_ENV_PREFIX = 'CADRE_';
67
+
68
+ /**
69
+ * Manager's own `process.env` with every `CADRE_*` key removed. A child node
70
+ * takes its whole configuration from the config file this orchestrator writes
71
+ * plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
72
+ * `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
73
+ * would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
74
+ * reconfigure every spawned child. Scrubbing the whole prefix rather than a
75
+ * list of known keys means a new cli env var can't reintroduce the leak by
76
+ * being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
77
+ * still pass through untouched.
78
+ */
79
+ function scrubbedParentEnv(): NodeJS.ProcessEnv {
80
+ const env = { ...process.env };
81
+ for (const key of Object.keys(env)) {
82
+ if (key.startsWith(CADRE_ENV_PREFIX)) delete env[key];
83
+ }
84
+ return env;
85
+ }
86
+
87
+ /**
88
+ * The env carrying how the child gets into its cadre, and nowhere else (see
89
+ * `HostedSpawnRequest`): `CADRE_CLAIM_SECRET` makes `cadre-cli start` wait to be
90
+ * claimed, `CADRE_INVITATION` makes it redeem the invitation once it is up.
91
+ */
92
+ function joinEnv(request: HostedSpawnRequest): Record<string, string> | undefined {
93
+ if (request.claimSecret) return { CADRE_CLAIM_SECRET: request.claimSecret };
94
+ if (request.invitation) return { CADRE_INVITATION: request.invitation };
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * The libp2p listen addresses a managed child binds — its `CADRE_LISTEN_ADDRS`, one
100
+ * entry per element: TCP on the `p2p` port and WebSocket on the `ws` port, both on
101
+ * every interface.
102
+ *
103
+ * The WebSocket entry is what lets a phone reach the child. A phone's node carries
104
+ * WebSocket, circuit-relay and WebRTC transports but no TCP
105
+ * (`reference-app-rn/src/phone-node-config.ts`), and it listens on nothing, so the
106
+ * phone has to be the one that dials. Naming a `/ws` address is all the child needs:
107
+ * cadre-core adds the WebSocket transport for any listen entry that names one
108
+ * (`resolveTransportOptions` in `cadre-core/src/relay-addrs.ts`).
109
+ */
110
+ export function childListenAddrs(ports: Pick<NodePorts, 'p2p' | 'ws'>): string[] {
111
+ return [`/ip4/0.0.0.0/tcp/${ports.p2p}`, `/ip4/0.0.0.0/tcp/${ports.ws}/ws`];
112
+ }
113
+
114
+ /**
115
+ * Every TCP port a managed child binds, on the address it binds it to: health and
116
+ * metrics (`cadre-cli`'s `HealthServer`) and the libp2p TCP and WebSocket listeners
117
+ * ({@link childListenAddrs}), all on every interface. A key the set lacks (a handle
118
+ * persisted by an older build) is skipped.
119
+ */
120
+ function childPortBindings(ports: Partial<NodePorts>): PortBinding[] {
121
+ const bindings: PortBinding[] = [];
122
+ for (const port of [ports.health, ports.metrics, ports.p2p, ports.ws]) {
123
+ if (port !== undefined) bindings.push({ port, host: '0.0.0.0' });
124
+ }
125
+ return bindings;
126
+ }
127
+
128
+ /**
129
+ * Runs cadre nodes as plain Node child processes on the host.
130
+ *
131
+ * Each container gets its own workdir under `rootDir`. Handles are mirrored
132
+ * to `<rootDir>/state.json` so cadre-host can re-attach after a restart.
133
+ *
134
+ * ## Child stdio: direct-fd inheritance
135
+ *
136
+ * Spawned children inherit a file descriptor opened on `<workdir>/node.log`
137
+ * directly as their stdout and stderr. The orchestrator closes its own copy
138
+ * of the fd immediately after spawn, so the child's write path no longer
139
+ * touches any handle owned by this process. This is what lets children
140
+ * survive — and keep logging through — an orchestrator restart. Were the
141
+ * orchestrator instead piping stdout/stderr through an in-process Writable,
142
+ * a parent exit would deliver SIGPIPE to the child (POSIX) or fail its
143
+ * writes with EPIPE (Windows), silently breaking the documented "init()
144
+ * re-attach" flow.
145
+ *
146
+ * ## Log rotation: spawn-time only
147
+ *
148
+ * Because the child holds a stable fd open against the inode, the
149
+ * orchestrator cannot safely rename `node.log` while the child is running.
150
+ * Rotation therefore happens only at spawn time, when no live child is
151
+ * attached to the workdir (`createContainer` is the single producer per
152
+ * workdir). If `node.log` already exceeds `logMaxBytes`, the rotation
153
+ * cascade runs against the on-disk paths before the fresh fd is opened.
154
+ * A child whose log grows past `logMaxBytes` mid-run will keep appending
155
+ * to the active file until it (or its successor) is restarted; v1 traffic
156
+ * is low enough that this is acceptable.
157
+ */
158
+ export class HostProcessOrchestrator implements Orchestrator {
159
+ private readonly rootDir: string;
160
+ private readonly portAllocator: PortAllocator;
161
+ private readonly stateStore: StateStore;
162
+ private readonly stopTimeoutMs: number;
163
+ private readonly logMaxBytes: number;
164
+ private readonly logMaxFiles: number;
165
+ private readonly defaultMemoryLimit?: string;
166
+ private readonly handles = new Map<string, Handle>();
167
+ private readonly cliEntrypoint: string;
168
+ /** Resolves push credentials fresh on every spawn (see HostProcessConfig). */
169
+ private readonly pushResolver?: PushCredentialsResolver;
170
+ /** The public addresses a node announces, asked at every spawn (see HostProcessConfig). */
171
+ private readonly announceAddrs?: HostProcessConfig['announceAddrs'];
172
+ /** State-change listeners — invoked when a handle's alive state changes. */
173
+ private readonly stateListeners = new Set<NodeStateListener>();
174
+
175
+ constructor(private readonly cfg: HostProcessConfig) {
176
+ this.rootDir = resolvePath(cfg.rootDir);
177
+ mkdirSync(this.rootDir, { recursive: true });
178
+ this.portAllocator = new PortAllocator(
179
+ cfg.portRange?.start ?? DEFAULTS.portRangeStart,
180
+ cfg.portRange?.end ?? DEFAULTS.portRangeEnd,
181
+ );
182
+ this.stateStore = new StateStore(this.rootDir);
183
+ this.stopTimeoutMs = cfg.stopTimeoutMs ?? DEFAULTS.stopTimeoutMs;
184
+ this.logMaxBytes = cfg.logMaxBytes ?? DEFAULTS.logMaxBytes;
185
+ this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
186
+ this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
187
+ this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
188
+ this.pushResolver = cfg.pushResolver;
189
+ this.announceAddrs = cfg.announceAddrs;
190
+ log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
191
+ }
192
+
193
+ /**
194
+ * Read persisted state and re-attach to surviving children. Not part of
195
+ * `Orchestrator` — call once before handing the instance to a container
196
+ * service.
197
+ */
198
+ async init(): Promise<void> {
199
+ const state = this.stateStore.load();
200
+ for (const persisted of state.handles) {
201
+ const handle: Handle = {
202
+ containerId: persisted.containerId,
203
+ dockerId: persisted.dockerId,
204
+ pid: persisted.pid,
205
+ startupToken: persisted.startupToken,
206
+ workdir: persisted.workdir,
207
+ ports: persisted.ports,
208
+ spawnedAt: persisted.spawnedAt,
209
+ partyId: persisted.partyId,
210
+ profile: persisted.profile,
211
+ announcedAddrs: persisted.announcedAddrs ?? [],
212
+ alive: false,
213
+ };
214
+ handle.alive = isHandleLive(handle);
215
+ this.handles.set(handle.dockerId, handle);
216
+ // Always reserve ports — even for dead handles, until the caller cleans them up
217
+ this.reservePorts(persisted.ports);
218
+ }
219
+ log('init: re-attached %d handles', state.handles.length);
220
+ }
221
+
222
+ /**
223
+ * Snapshot of currently-known managed nodes. Used by the local UI's
224
+ * `/api/nodes` route.
225
+ */
226
+ listNodes(): ManagedNodeInfo[] {
227
+ return [...this.handles.values()].map((h) => toNodeInfo(h));
228
+ }
229
+
230
+ /**
231
+ * Look up a single managed node by either containerId (friendly id) or
232
+ * dockerId (opaque `pid:token`). Returns undefined when neither matches.
233
+ */
234
+ getNode(idOrDockerId: string): ManagedNodeInfo | undefined {
235
+ const direct = this.handles.get(idOrDockerId);
236
+ if (direct) return toNodeInfo(direct);
237
+ for (const h of this.handles.values()) {
238
+ if (h.containerId === idOrDockerId) return toNodeInfo(h);
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ /**
244
+ * Resolve the opaque dockerId for a friendly containerId so callers can
245
+ * pass it into stopContainer / getLogs / getStats.
246
+ */
247
+ resolveDockerId(idOrDockerId: string): string | undefined {
248
+ if (this.handles.has(idOrDockerId)) return idOrDockerId;
249
+ for (const h of this.handles.values()) {
250
+ if (h.containerId === idOrDockerId) return h.dockerId;
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ /**
256
+ * Register a state-change listener. Invoked when a handle's `alive`
257
+ * transitions (create, child exit, manual stop) and once more, as `stopped`,
258
+ * when a handle is removed. Returns an unsubscribe fn.
259
+ */
260
+ onStateChange(listener: NodeStateListener): () => void {
261
+ this.stateListeners.add(listener);
262
+ return () => { this.stateListeners.delete(listener); };
263
+ }
264
+
265
+ /**
266
+ * Spawn a managed node — a hosted node for some cadre, started waiting to be
267
+ * claimed when the request carries a `claimSecret`, or redeeming the cadre
268
+ * invitation it carries as `invitation`. The node is given its own
269
+ * protobuf identity key inside its workdir (`ensureNodeIdentity`, reused across
270
+ * re-spawns of the same containerId), which is what makes its peer id stable
271
+ * across restarts AND what makes its node-local stores durable: `cadre-cli
272
+ * start` opens the file-backed bootstrap-peer, trusted-owner and strand
273
+ * network-state stores in the node's state directory, which defaults to the
274
+ * directory holding the `cadre.json` written here — the same workdir.
275
+ * Removing the node (`removeContainer`) deletes the workdir, so the key and
276
+ * every store go with it.
277
+ */
278
+ async createContainer(request: HostedSpawnRequest): Promise<OrchestratorCreateResult> {
279
+ // Before anything is allocated: `cadre-cli start` refuses the pair anyway, and a child
280
+ // that exits at once would only surface as a crash loop.
281
+ if (request.claimSecret && request.invitation) {
282
+ throw new Error(`container ${request.containerId}: a spawn carries a claim secret or an invitation, not both`);
283
+ }
284
+ const workdir = this.workdirFor(request.containerId);
285
+ // Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
286
+ // existence as a side effect of writing the key. Only a spawn that found
287
+ // nothing here may delete anything on its way out — a re-spawn is REUSING
288
+ // the identity key and node-local stores this directory holds, and they are
289
+ // the whole reason the node comes back as the same peer. `false` is
290
+ // therefore the safe default: a directory that exists for any reason
291
+ // (respawn, an earlier failed provision, an operator's file) is never
292
+ // touched by the unwind below.
293
+ const isNewWorkdir = !existsSync(workdir);
294
+ let dropped: Handle[] = [];
295
+ let ports: NodePorts | undefined;
296
+ try {
297
+ // Identity BEFORE any port is reserved: it is the one step here that can
298
+ // fail on its own (an unreadable or undecodable identity.key throws rather
299
+ // than silently re-keying), and everything past the allocation is either
300
+ // infallible or releases the ports itself. Allocating first would leak a
301
+ // node's worth of ports from a bounded range on every failed provision attempt.
302
+ const identity = await ensureNodeIdentity(workdir);
303
+ log('container %s identity peerId=%s', request.containerId, identity.peerId);
304
+ // Only storage-profile nodes participate in strands and thus fan out push
305
+ // wakes — a transaction-only node need not carry credentials. Resolved
306
+ // BEFORE the drop below, which keeps the drop → launch window synchronous
307
+ // (see restoreDroppedHandles).
308
+ const push = request.profile === 'storage' ? await this.resolvePush() : undefined;
309
+ const extraEnv = joinEnv(request);
310
+
311
+ // The last `await` on this path: checked as close to the drop as it can be.
312
+ await this.refuseRespawnOverLiveChild(request.containerId);
313
+
314
+ // Nothing from here to the launch is `await`ed — which is what keeps
315
+ // `restoreDroppedHandles`'s documented precondition true even though the
316
+ // `try` now opens further up. Do not add an `await` past this line.
317
+ dropped = this.dropStaleHandle(request.containerId);
318
+ // A re-spawn comes back on the ports the dropped handle held, not merely the
319
+ // lowest free ones: a requester that dials the node itself (a phone) knows it
320
+ // only by the address it was given. The drop above just released them, and
321
+ // nothing can have taken them since — see reusedNodePorts.
322
+ ports = allocateNodePorts(this.portAllocator, reusedNodePorts(dropped));
323
+ return this.launchChild({
324
+ containerId: request.containerId,
325
+ partyId: request.partyId,
326
+ profile: request.profile,
327
+ ports,
328
+ buildConfig: () => this.buildChildConfig(request, workdir, push),
329
+ extraArgs: ['--identity-file', identity.path],
330
+ ...(extraEnv ? { extraEnv } : {}),
331
+ ...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
332
+ });
333
+ } catch (err) {
334
+ // Release BEFORE restoring: a re-spawn allocates the very ports the drop
335
+ // just freed, so restoring first and releasing second would hand the
336
+ // restored handle's own ports straight back to the allocator.
337
+ if (ports) this.releasePorts(ports);
338
+ // A no-op for the failures that happen before the drop (`dropped` is
339
+ // still empty).
340
+ this.restoreDroppedHandles(dropped);
341
+ // Safe because a throw out of `launchChild` means NO child is running:
342
+ // its throw surface ends where the child is spawned and its handle stored
343
+ // (everything past that line is best-effort), and the `if (!child.pid)`
344
+ // throw is on the no-process side too. So nothing is ever holding the
345
+ // directory this deletes.
346
+ if (isNewWorkdir) this.discardWorkdir(workdir);
347
+ throw err;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Remove the working directory a spawn created for `containerId` when no
353
+ * handle owns it — the cleanup of last resort for a hosted-node record that
354
+ * never got a `dockerId` (the host died between `ensureNodeIdentity` and the
355
+ * handle write, so `createContainer`'s own unwind never ran). Returns whether
356
+ * anything was removed.
357
+ *
358
+ * Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
359
+ * to await.
360
+ */
361
+ reclaimWorkdir(containerId: string): boolean {
362
+ // A live handle owns that directory, and `removeContainer` — which stops
363
+ // the child first — is the only thing allowed to delete it.
364
+ if (this.resolveDockerId(containerId)) return false;
365
+ const workdir = this.workdirFor(containerId);
366
+ // Defence, not a live case: hosted-node ids are `hn_<base64url>` and so can
367
+ // hold neither a path separator nor a dot. A caller that ever passes
368
+ // something else must not be able to walk out of `rootDir`.
369
+ if (!workdir.startsWith(this.rootDir + sep)) {
370
+ log('refusing to reclaim workdir outside rootDir: %s', workdir);
371
+ return false;
372
+ }
373
+ // Must be a DIRECTORY, not merely something that exists: `rootDir` also
374
+ // holds `state.json` (this orchestrator's own persisted handles), and
375
+ // `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
376
+ // defence class as the escape check above — no real containerId names a
377
+ // file — but the blast radius if one ever did is the whole handle map.
378
+ if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory()) return false;
379
+ this.discardWorkdir(workdir);
380
+ return true;
381
+ }
382
+
383
+ /**
384
+ * Best-effort removal of a workdir no handle owns. Logs, never throws: every
385
+ * caller is either unwinding a failure it must report instead of this one, or
386
+ * tearing a container down along a path where a surviving directory is a
387
+ * nuisance rather than a fault.
388
+ *
389
+ * `retries` sizes the wait for the OS to release a cwd handle — Windows drops
390
+ * it asynchronously after a child exits. `createContainer`'s unwind needs none
391
+ * of that (its throw surface ends before any child is spawned), but
392
+ * `reclaimWorkdir` can still meet an orphan process the previous host left
393
+ * running with this directory as its cwd, so it is not a *guaranteed*
394
+ * no-process path either — it simply logs and leaves the directory when the
395
+ * removal fails.
396
+ */
397
+ private discardWorkdir(workdir: string, retries = { maxRetries: 5, retryDelay: 100 }): void {
398
+ try {
399
+ rmSync(workdir, { recursive: true, force: true, ...retries });
400
+ log('discarded workdir %s', workdir);
401
+ } catch (err) {
402
+ log('failed to discard workdir %s: %o', workdir, err);
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Resolve the push credentials to inject into the node about to spawn. Called
408
+ * on every spawn so a re-spawn re-reads the secret store — raw private keys are
409
+ * never persisted. A resolver failure (e.g. a partial credential set) is
410
+ * logged and degrades to "no push" rather than failing the spawn: the node
411
+ * stays up and reachable, just without platform push-wake.
412
+ */
413
+ private async resolvePush(): Promise<PushCredentials | undefined> {
414
+ if (!this.pushResolver) return undefined;
415
+ try {
416
+ const push = await this.pushResolver();
417
+ if (push) {
418
+ log('resolved push credentials: fcm=%s apns=%s cooldownMs=%s', !!push.fcm, !!push.apns, push.cooldownMs);
419
+ }
420
+ return push;
421
+ } catch (err) {
422
+ log('push resolver failed; spawning node without push: %s', (err as Error).message);
423
+ return undefined;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * The public addresses the node about to spawn announces. A hook that throws costs
429
+ * the node its public addresses, never its start: it still binds and is reachable
430
+ * on its LAN addresses, and `NatService` restarts it once the addresses it should
431
+ * announce differ from these.
432
+ */
433
+ private announceAddrsFor(containerId: string, ports: NodePorts): string[] {
434
+ if (!this.announceAddrs) return [];
435
+ try {
436
+ return this.announceAddrs(containerId, ports);
437
+ } catch (err) {
438
+ log('announce hook failed for %s; it starts with no public addresses: %s', containerId, (err as Error).message);
439
+ return [];
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Core spawn mechanics behind `createContainer`.
445
+ *
446
+ * The caller has already allocated `ports` and **owns their lifetime**: this
447
+ * method never releases them, so the caller must release on any throw out
448
+ * of here (it has to unwind its handle drop on the same path anyway — see
449
+ * `restoreDroppedHandles`).
450
+ *
451
+ * That contract holds because this method throws only *before* the child is
452
+ * spawned and its handle stored — everything after that point is
453
+ * best-effort. Anything new added at the tail must keep it that way.
454
+ *
455
+ * Entirely synchronous, which is what makes that unwind sound. Do not add an
456
+ * `await` here.
457
+ */
458
+ private launchChild(opts: {
459
+ containerId: string;
460
+ partyId: string;
461
+ profile: 'storage' | 'transaction';
462
+ ports: NodePorts;
463
+ buildConfig: (workdir: string) => CliConfig;
464
+ extraArgs: string[];
465
+ /** Extra env vars merged into the child's environment (e.g. CADRE_CLAIM_SECRET). */
466
+ extraEnv?: Record<string, string>;
467
+ memoryLimit?: string;
468
+ }): OrchestratorCreateResult {
469
+ const { containerId, ports } = opts;
470
+ const workdir = this.workdirFor(containerId);
471
+ mkdirSync(workdir, { recursive: true });
472
+ mkdirSync(join(workdir, 'storage'), { recursive: true });
473
+
474
+ const token = randomBytes(16).toString('hex');
475
+ const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
476
+ // Ensure stale token from a previous container with the same id is cleared.
477
+ if (existsSync(tokenPath)) {
478
+ try { rmSync(tokenPath); } catch { /* ignore */ }
479
+ }
480
+
481
+ const configPath = join(workdir, CONFIG_FILE);
482
+ writeFileSync(configPath, JSON.stringify(opts.buildConfig(workdir), null, 2), 'utf8');
483
+
484
+ const memoryLimit = opts.memoryLimit ?? this.defaultMemoryLimit;
485
+ const heapBytes = parseMemoryLimit(memoryLimit);
486
+ const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
487
+ const announcedAddrs = this.announceAddrsFor(containerId, ports);
488
+
489
+ const env: NodeJS.ProcessEnv = {
490
+ // Scrubbed, not raw process.env: every CADRE_* config-override key is
491
+ // stripped so a var set on the manager can't silently reconfigure every
492
+ // child (see scrubbedParentEnv). Caller-supplied extras come next — the
493
+ // fixed per-child vars below always win so extraEnv can never clobber
494
+ // the startup token or port wiring.
495
+ ...scrubbedParentEnv(),
496
+ ...opts.extraEnv,
497
+ CADRE_STARTUP_TOKEN: token,
498
+ CADRE_HEALTH_PORT: String(ports.health),
499
+ CADRE_METRICS_PORT: String(ports.metrics),
500
+ CADRE_LISTEN_ADDRS: childListenAddrs(ports).join(','),
501
+ // The append form, not `CADRE_ANNOUNCE_ADDRS`: that one replaces the advertised
502
+ // set and would drop the LAN addresses a phone at home dials. Every entry must be
503
+ // a valid multiaddr: cadre-core refuses to start on one that is not.
504
+ // The TCP and WebSocket ports are the CHILD'S CONTROL NODE's alone: each strand
505
+ // node the child runs binds the same two entries with OS-assigned ports instead,
506
+ // since one port cannot be held twice, and inherits neither announce field
507
+ // (`cadre-core/src/strand-network-config.ts`). Those ports are not mapped, and
508
+ // strand nodes are reached through observed addresses only, since no
509
+ // `CADRE_RELAY_ADDRS` is set here and a child therefore holds no relay reservation.
510
+ ...(announcedAddrs.length > 0 ? { CADRE_APPEND_ANNOUNCE_ADDRS: announcedAddrs.join(',') } : {}),
511
+ // Pin each child's node-local state (trusted-owner anchor, retained
512
+ // cold-start dial targets) to its OWN workdir. This is the same value the
513
+ // cli would derive by default (the config file lives here too), but stated
514
+ // explicitly rather than relying on the scrub above (belt and suspenders).
515
+ CADRE_NODE_STATE_DIR: workdir,
516
+ };
517
+ if (heapMB !== undefined) {
518
+ env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
519
+ }
520
+
521
+ // Rotate the on-disk log files BEFORE opening the fd — once the child
522
+ // is running it holds the inode open and any later rename would leave
523
+ // it writing to the rotated file. See class docstring.
524
+ const logPath = defaultLogPath(workdir);
525
+ maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
526
+ const logFd = openSync(logPath, 'a');
527
+
528
+ const args = [
529
+ this.cliEntrypoint,
530
+ 'start',
531
+ '-c', configPath,
532
+ '--health-port', String(ports.health),
533
+ '--metrics-port', String(ports.metrics),
534
+ '--startup-token-file', tokenPath,
535
+ ...opts.extraArgs,
536
+ ];
537
+
538
+ let child: ChildProcess;
539
+ try {
540
+ child = spawn(process.execPath, args, {
541
+ cwd: workdir,
542
+ detached: true,
543
+ // Inherit the log fd directly. OS duplicates it into the child;
544
+ // closing our copy below is safe and severs the parent's only
545
+ // tie to the child's write path.
546
+ stdio: ['ignore', logFd, logFd],
547
+ env,
548
+ });
549
+ } catch (err) {
550
+ // Synchronous spawn failure — close the fd before propagating. This is
551
+ // the ONLY fd-leak guard on this path: nothing between the `openSync`
552
+ // above and the `spawn` can throw, so no other catch is needed. Keep it
553
+ // that way, or add one. (The ports are the caller's to release.)
554
+ closeSync(logFd);
555
+ throw err;
556
+ }
557
+ // Always release the parent's copy of the fd — the child has its own.
558
+ closeSync(logFd);
559
+
560
+ if (!child.pid) {
561
+ throw new Error(`Failed to spawn child for container ${containerId}`);
562
+ }
563
+
564
+ child.unref();
565
+
566
+ const dockerId = encodeDockerId(child.pid, token);
567
+ const handle: Handle = {
568
+ containerId,
569
+ dockerId,
570
+ pid: child.pid,
571
+ startupToken: token,
572
+ workdir,
573
+ ports,
574
+ spawnedAt: new Date().toISOString(),
575
+ partyId: opts.partyId,
576
+ profile: opts.profile,
577
+ announcedAddrs,
578
+ child,
579
+ alive: true,
580
+ };
581
+
582
+ child.once('exit', () => {
583
+ const h = this.handles.get(dockerId);
584
+ if (h && h.alive) {
585
+ h.alive = false;
586
+ this.emitStateChange(h);
587
+ }
588
+ });
589
+
590
+ this.handles.set(dockerId, handle);
591
+ // Past this line the launch has SUCCEEDED: the child is running and this
592
+ // handle owns its ports. So the state mirror is best-effort — a throw here
593
+ // would reach the caller's unwind, which would release ports the live child
594
+ // is bound to and restore the handle this spawn just replaced, leaving two
595
+ // handles for one container and one of them holding unreserved ports. A
596
+ // lost write self-heals: the next persist() (any stop/remove/spawn)
597
+ // rewrites the whole map. Until then a manager restart would fail to
598
+ // re-attach this child, hence the log.
599
+ try {
600
+ this.persist();
601
+ } catch (err) {
602
+ log('failed to persist state after launching container %s: %o', containerId, err);
603
+ }
604
+ this.emitStateChange(handle);
605
+
606
+ log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
607
+
608
+ return {
609
+ dockerId,
610
+ healthEndpoint: `http://localhost:${ports.health}/health`,
611
+ metricsEndpoint: `http://localhost:${ports.metrics}/metrics`,
612
+ p2pPort: ports.p2p,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Refuse to re-spawn `containerId` while a previous child of it may still be
618
+ * running. `createContainer` calls this as its last `await` before the handle
619
+ * drop, so a refusal happens while nothing has been released, no token rotated
620
+ * and no child launched — the caller sees an ordinary failed spawn.
621
+ *
622
+ * Why it exists: the drop hands the old handle's ports back and the launch takes
623
+ * those same ports (`reusedNodePorts`), so a child started over a live one dies
624
+ * on a port clash — but only after `launchChild` has returned success. The
625
+ * caller then records a dead child, and the live one keeps running with no
626
+ * handle naming it.
627
+ *
628
+ * - A handle this process spawned is checked exactly ({@link isHandleLive}); a
629
+ * live one refuses outright.
630
+ * - A handle re-attached from `state.json` whose pid is alive but whose token
631
+ * does not match is either our child still starting or an unrelated process
632
+ * that inherited the pid. The ports decide: if any the re-spawn would take back
633
+ * is already bound, refuse. The pid is never killed on that evidence — it may
634
+ * not be ours.
635
+ *
636
+ * NOTE: the probe cannot see a re-attached child still loading its modules —
637
+ * `cadre-cli start` writes its token and only then binds, so a child in that
638
+ * window shows neither, and a re-spawn launched then races it for the ports.
639
+ * Reachable only when the host restarts within a child's first seconds. If it
640
+ * is ever seen, treat a re-attached handle with a live pid as starting for a
641
+ * grace period after its `spawnedAt`.
642
+ *
643
+ * NOTE: the probe holds each port for an instant, so a child binding that port
644
+ * in the same instant fails. It would have had to write its token between the
645
+ * token check above and the probe, against the several awaited steps
646
+ * `cadre-cli start` takes between writing its token and binding; if a re-attached
647
+ * child is ever seen dying on its own port right after a host restart, probe by
648
+ * connecting instead of binding.
649
+ */
650
+ private async refuseRespawnOverLiveChild(containerId: string): Promise<void> {
651
+ const previous = this.handlesFor(containerId);
652
+ if (previous.some(isHandleLive)) {
653
+ throw new Error(`container ${containerId} is still running`);
654
+ }
655
+ if (!previous.some((h) => isPidAlive(h.pid))) return;
656
+ const bindings = childPortBindings(reusedNodePorts(previous));
657
+ try {
658
+ await Promise.all(bindings.map(assertPortFree));
659
+ } catch (err) {
660
+ throw new Error(
661
+ `container ${containerId} may still be running: ${(err as Error).message}`,
662
+ { cause: err },
663
+ );
664
+ }
665
+ }
666
+
667
+ /** Every handle for `containerId`, in map order — the order `dropStaleHandle` drops them in. */
668
+ private handlesFor(containerId: string): Handle[] {
669
+ return [...this.handles.values()].filter((h) => h.containerId === containerId);
670
+ }
671
+
672
+ /**
673
+ * Drop any handle left over from a previous spawn of the same `containerId`
674
+ * and release its ports. Handles are keyed by the per-spawn `dockerId`, so
675
+ * without this a re-spawn (a hosted node's respawn) would strand the prior
676
+ * handle in the map forever, leaking its ports from a bounded range each
677
+ * time.
678
+ *
679
+ * DO NOT delete the workdir: the identity key and node-local stores
680
+ * (trusted-owner anchor, retained cold-start dial targets) live there and are
681
+ * the whole reason a respawn comes back as the same node. `launchChild`
682
+ * reuses the same workdir.
683
+ *
684
+ * Releasing the ports hands them straight back to the allocator, and the
685
+ * re-spawn then takes those same ports as overrides (`reusedNodePorts`), so a
686
+ * re-spawn while the *previous* child is still listening would bind-clash.
687
+ * The caller must therefore never drop a live child, and does so only after
688
+ * {@link refuseRespawnOverLiveChild} has passed. A caller that needs to replace
689
+ * a live child must stop it first.
690
+ *
691
+ * **The drop is reversible.** It happens before the launch can fail, so the
692
+ * dropped handles are returned and the caller restores them via
693
+ * {@link restoreDroppedHandles} when the launch throws — otherwise the caller
694
+ * would be left holding a dockerId this orchestrator no longer knows, and the
695
+ * node's workdir could never be reclaimed. On a *successful* spawn the drop
696
+ * stands: `HostedNodeService.abandonRespawn` depends on the old handle being
697
+ * gone by then.
698
+ */
699
+ private dropStaleHandle(containerId: string): Handle[] {
700
+ const dropped = this.handlesFor(containerId);
701
+ for (const h of dropped) {
702
+ log('dropping stale handle %s for container %s', h.dockerId, containerId);
703
+ this.releasePorts(h.ports);
704
+ this.handles.delete(h.dockerId);
705
+ }
706
+ return dropped;
707
+ }
708
+
709
+ /**
710
+ * Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
711
+ * handle back in the map and re-reserve its ports, leaving host state
712
+ * exactly as the failed attempt found it.
713
+ *
714
+ * **Precondition: the drop → launch window is synchronous.** Restoring is
715
+ * sound only because nothing can have taken those ports or run cleanup
716
+ * against the handle in between — `launchChild` contains no `await` and the
717
+ * caller resolves push credentials *before* its drop. `launchChild`'s half
718
+ * is enforced: its declared return type is not a Promise, so adding an
719
+ * `await` there is a compile error. The caller's half is not — an `await`
720
+ * slipped between a drop and its launch silently breaks this.
721
+ *
722
+ * No `persist()` here: `state.json` is rewritten only by `launchChild` and
723
+ * only *after* the new handle is stored, so throughout the failure window the
724
+ * on-disk state still lists the dropped handle — this restore realigns memory
725
+ * to disk rather than diverging from it.
726
+ *
727
+ * NOTE: a restored handle keeps `alive: true` even if its child exited while
728
+ * it was out of the map (`launchChild`'s exit listener looks the handle up by
729
+ * dockerId and finds nothing). Only reachable by dropping a handle whose child
730
+ * is still live, which {@link refuseRespawnOverLiveChild} refuses before any
731
+ * drop. (Its start-up blind spot concerns re-attached handles only, and those
732
+ * have no exit listener to miss.)
733
+ */
734
+ private restoreDroppedHandles(dropped: Handle[]): void {
735
+ for (const h of dropped) {
736
+ log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
737
+ this.handles.set(h.dockerId, h);
738
+ this.reservePorts(h.ports);
739
+ }
740
+ }
741
+
742
+ /** A managed node's own directory under `rootDir` — config, log, storage, identity. */
743
+ private workdirFor(containerId: string): string {
744
+ return join(this.rootDir, containerId);
745
+ }
746
+
747
+ /**
748
+ * Inverse of {@link releasePorts} — hold a handle's whole port set against
749
+ * the allocator. Used both to rehydrate on `init` and to undo a drop whose
750
+ * launch then failed.
751
+ */
752
+ private reservePorts(ports: NodePorts): void {
753
+ reserveNodePorts(this.portAllocator, ports);
754
+ }
755
+
756
+ private releasePorts(ports: NodePorts): void {
757
+ releaseNodePorts(this.portAllocator, ports);
758
+ }
759
+
760
+ async stopContainer(dockerId: string): Promise<void> {
761
+ const handle = this.requireHandle(dockerId);
762
+ if (!isPidAlive(handle.pid)) {
763
+ handle.alive = false;
764
+ this.persist();
765
+ return;
766
+ }
767
+
768
+ // Capture the exit event before we send signals so we can await full
769
+ // teardown — important on Windows where cwd handles release lazily.
770
+ const exitPromise = handle.child
771
+ ? new Promise<void>((resolve) => handle.child!.once('exit', () => resolve()))
772
+ : Promise.resolve();
773
+
774
+ log('SIGTERM container %s (pid=%d)', handle.containerId, handle.pid);
775
+ try {
776
+ process.kill(handle.pid, 'SIGTERM');
777
+ } catch (err) {
778
+ const code = (err as NodeJS.ErrnoException).code;
779
+ if (code !== 'ESRCH') {
780
+ throw err;
781
+ }
782
+ }
783
+
784
+ const deadline = Date.now() + this.stopTimeoutMs;
785
+ while (Date.now() < deadline) {
786
+ if (!isPidAlive(handle.pid)) break;
787
+ await sleep(LIVENESS_POLL_MS);
788
+ }
789
+
790
+ if (isPidAlive(handle.pid)) {
791
+ log('SIGKILL container %s (pid=%d)', handle.containerId, handle.pid);
792
+ try {
793
+ process.kill(handle.pid, 'SIGKILL');
794
+ } catch (err) {
795
+ const code = (err as NodeJS.ErrnoException).code;
796
+ if (code !== 'ESRCH') {
797
+ throw err;
798
+ }
799
+ }
800
+ const killDeadline = Date.now() + 2000;
801
+ while (Date.now() < killDeadline && isPidAlive(handle.pid)) {
802
+ await sleep(LIVENESS_POLL_MS);
803
+ }
804
+ }
805
+
806
+ // Wait briefly for the OS to fully tear down the child (Windows cwd lock).
807
+ await Promise.race([exitPromise, sleep(1000)]);
808
+
809
+ const wasAlive = handle.alive;
810
+ handle.alive = false;
811
+ this.persist();
812
+ if (wasAlive) this.emitStateChange(handle);
813
+ }
814
+
815
+ async removeContainer(dockerId: string): Promise<void> {
816
+ const handle = this.requireHandle(dockerId);
817
+ if (isPidAlive(handle.pid)) {
818
+ await this.stopContainer(dockerId);
819
+ }
820
+
821
+ this.releasePorts(handle.ports);
822
+
823
+ // Longer retries than the other callers': this is the one path that has
824
+ // just killed a child which held this directory as its cwd, and on Windows
825
+ // the OS releases that handle asynchronously.
826
+ this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
827
+
828
+ this.handles.delete(dockerId);
829
+ this.persist();
830
+ // Removal is a state change too: `NatService` releases the node's port
831
+ // mappings on it at once rather than at its next timer pass. A stopped
832
+ // event for a node no longer in `listNodes()` is how listeners tell a
833
+ // removal from a stop.
834
+ this.emitStateChange(handle);
835
+ }
836
+
837
+ /**
838
+ * Whether the child `dockerId` names is running — see {@link isHandleLive}. An
839
+ * id with no handle is not running: without the handle's workdir there is no
840
+ * token to verify a live pid against.
841
+ */
842
+ async isRunning(dockerId: string): Promise<boolean> {
843
+ const handle = this.handles.get(dockerId);
844
+ if (!handle) return false;
845
+ if (isHandleLive(handle)) return true;
846
+ if (!isPidAlive(handle.pid)) handle.alive = false;
847
+ return false;
848
+ }
849
+
850
+ async getStats(dockerId: string): Promise<OrchestratorStats> {
851
+ const handle = this.requireHandle(dockerId);
852
+ if (!isPidAlive(handle.pid)) {
853
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
854
+ }
855
+ try {
856
+ const usage = await pidusage(handle.pid);
857
+ return {
858
+ cpuPercent: usage.cpu,
859
+ memoryBytes: usage.memory,
860
+ networkRxBytes: 0,
861
+ networkTxBytes: 0,
862
+ };
863
+ } catch (err) {
864
+ log('pidusage failed for %d: %o', handle.pid, err);
865
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
866
+ }
867
+ }
868
+
869
+ async getLogs(dockerId: string, tail = 100): Promise<string> {
870
+ const handle = this.requireHandle(dockerId);
871
+ const logPath = defaultLogPath(handle.workdir);
872
+ return tailFile(logPath, tail);
873
+ }
874
+
875
+ private requireHandle(dockerId: string): Handle {
876
+ const handle = this.handles.get(dockerId);
877
+ if (!handle) {
878
+ throw new Error(`Container not found: ${dockerId}`);
879
+ }
880
+ return handle;
881
+ }
882
+
883
+ private buildChildConfig(
884
+ req: OrchestratorCreateRequest,
885
+ workdir: string,
886
+ push?: PushCredentials,
887
+ ): CliConfig {
888
+ const cfg: CliConfig = {
889
+ controlNetwork: {
890
+ partyId: req.partyId,
891
+ // A hosted node is dialed (a claim) or dials the members its invitation names.
892
+ bootstrapNodes: [],
893
+ },
894
+ profile: req.profile,
895
+ // The request carries the filter as text (the provider hands the same text to
896
+ // CADRE_STRAND_FILTER); the file form is `all`/`none` or the parsed JSON object, and a
897
+ // filter the node would refuse fails here, before anything is spawned.
898
+ // NOTE: an empty string is refused here, where cadre-provider's container-env treats it as
899
+ // unset. Nothing in cadre-host populates `req.strandFilter` today; if the provision route
900
+ // starts accepting one, pick one rule for both.
901
+ strandFilter: req.strandFilter === undefined ? 'all' : strandFilterConfigFromText(req.strandFilter),
902
+ };
903
+ if (req.profile === 'storage') {
904
+ cfg.storage = {
905
+ type: 'file',
906
+ path: join(workdir, 'storage'),
907
+ ...(req.resources?.storageQuotaBytes !== undefined
908
+ ? { quotaBytes: req.resources.storageQuotaBytes }
909
+ : {}),
910
+ };
911
+ }
912
+ // Push credentials land in the child's cadre.json on the host filesystem —
913
+ // the same trust boundary as the control-DB storage in this workdir. The
914
+ // private keys are NOT logged (the resolver redacts; we log only presence).
915
+ if (push) cfg.push = push;
916
+ return cfg;
917
+ }
918
+
919
+ private persist(): void {
920
+ const state: PersistedState = {
921
+ version: 1,
922
+ handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
923
+ };
924
+ this.stateStore.save(state);
925
+ }
926
+
927
+ private emitStateChange(handle: Handle): void {
928
+ if (this.stateListeners.size === 0) return;
929
+ const info = toNodeInfo(handle);
930
+ for (const listener of this.stateListeners) {
931
+ try {
932
+ listener(info);
933
+ } catch (err) {
934
+ log('state listener threw for %s: %o', info.id, err);
935
+ }
936
+ }
937
+ }
938
+
939
+ private toPersisted(h: Handle): PersistedHandle {
940
+ return {
941
+ containerId: h.containerId,
942
+ dockerId: h.dockerId,
943
+ pid: h.pid,
944
+ startupToken: h.startupToken,
945
+ workdir: h.workdir,
946
+ ports: h.ports,
947
+ spawnedAt: h.spawnedAt,
948
+ partyId: h.partyId,
949
+ profile: h.profile,
950
+ announcedAddrs: h.announcedAddrs,
951
+ };
952
+ }
953
+ }
954
+
955
+ function toNodeInfo(h: Handle): ManagedNodeInfo {
956
+ return {
957
+ id: h.containerId,
958
+ dockerId: h.dockerId,
959
+ partyId: h.partyId,
960
+ profile: h.profile,
961
+ status: h.alive ? 'running' : 'stopped',
962
+ spawnedAt: h.spawnedAt,
963
+ workdir: h.workdir,
964
+ ports: { ...h.ports },
965
+ announcedAddrs: [...h.announcedAddrs],
966
+ };
967
+ }
968
+
969
+ /**
970
+ * Locate the cadre-cli bin via the consumer-style module resolver. Works for
971
+ * both yarn-managed workspaces and traditional npm installs.
972
+ */
973
+ function resolveCadreCliBin(): string {
974
+ const req = createRequire(import.meta.url);
975
+ try {
976
+ return req.resolve('@serfab/cadre-cli/bin/cadre.js');
977
+ } catch (err) {
978
+ throw new Error(
979
+ `Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
980
+ `Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
981
+ `and listed as a dependency. Underlying error: ${(err as Error).message}`,
982
+ { cause: err },
983
+ );
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Whether the process a handle names is still the child it was launched as — the
989
+ * one liveness rule behind `init`, `isRunning` and the pre-spawn guard, so they
990
+ * cannot drift apart.
991
+ *
992
+ * A handle this process spawned carries its `ChildProcess`, and that answer is
993
+ * exact from the moment of spawn: no exit seen, and the pid still answering. (The
994
+ * pid check covers the moment between the OS ending the process and Node
995
+ * delivering its exit; the pid cannot have been reused in that moment, since the
996
+ * OS holds it until Node has reaped the child.) The startup-token file is no use
997
+ * here — a child writes it only once it is running its own code, so reading it
998
+ * would report every child dead for its first moments.
999
+ *
1000
+ * A handle re-attached from `state.json` after a host restart has no
1001
+ * `ChildProcess`, so it falls back to the pid plus that token file, which tells our
1002
+ * child apart from an unrelated process that inherited its pid.
1003
+ */
1004
+ function isHandleLive(handle: Handle): boolean {
1005
+ const { child } = handle;
1006
+ if (child) {
1007
+ return child.exitCode === null && child.signalCode === null && isPidAlive(handle.pid);
1008
+ }
1009
+ return isPidAlive(handle.pid) && tokenMatches(handle.workdir, handle.startupToken);
1010
+ }
1011
+
1012
+ function tokenMatches(workdir: string, expected: string): boolean {
1013
+ const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
1014
+ try {
1015
+ const contents = readFileSync(tokenPath, 'utf8').trim();
1016
+ return contents === expected;
1017
+ } catch {
1018
+ return false;
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Rotate the on-disk log files for a workdir if the active file is already
1024
+ * at or above the byte threshold. Called from `createContainer` before the
1025
+ * child's stdio fd is opened — the only safe rotation point because the
1026
+ * caller guarantees no live child is attached to this workdir.
1027
+ */
1028
+ function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
1029
+ if (maxBytes <= 0 || maxFiles < 1) return;
1030
+ let size: number;
1031
+ try {
1032
+ size = statSync(logPath).size;
1033
+ } catch {
1034
+ return;
1035
+ }
1036
+ if (size >= maxBytes) {
1037
+ rotateOnDisk(logPath, maxFiles);
1038
+ }
1039
+ }
1040
+
1041
+ function sleep(ms: number): Promise<void> {
1042
+ return new Promise((res) => {
1043
+ const t = setTimeout(res, ms);
1044
+ if (typeof t.unref === 'function') t.unref();
1045
+ });
1046
+ }
1047
+
1048
+ /**
1049
+ * Read the last `n` lines from a log file. Reads from the tail in chunks
1050
+ * rather than slurping the whole file.
1051
+ */
1052
+ function tailFile(path: string, n: number): string {
1053
+ if (!existsSync(path)) return '';
1054
+ const fd = openSync(path, 'r');
1055
+ try {
1056
+ const size = statSync(path).size;
1057
+ const chunkSize = 8192;
1058
+ let pos = size;
1059
+ const buffers: Buffer[] = [];
1060
+ let newlineCount = 0;
1061
+ while (pos > 0 && newlineCount <= n) {
1062
+ const readLen = Math.min(chunkSize, pos);
1063
+ pos -= readLen;
1064
+ const buf = Buffer.alloc(readLen);
1065
+ readSync(fd, buf, 0, readLen, pos);
1066
+ buffers.unshift(buf);
1067
+ for (let i = 0; i < buf.length; i++) {
1068
+ if (buf[i] === 0x0a) newlineCount++;
1069
+ }
1070
+ }
1071
+ const joined = Buffer.concat(buffers).toString('utf8');
1072
+ const lines = joined.split('\n');
1073
+ // Drop any trailing empty line from a terminating newline before counting.
1074
+ const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
1075
+ return trimmed.slice(-n).join('\n');
1076
+ } finally {
1077
+ closeSync(fd);
1078
+ }
1079
+ }
1080
+
1081
+ function parseMemoryLimit(limit?: string): number | undefined {
1082
+ if (!limit) return undefined;
1083
+ const match = limit.match(/^(\d+(?:\.\d+)?)\s*(B|K|M|G|T)?$/i);
1084
+ if (!match) return undefined;
1085
+ const [, num, unit] = match;
1086
+ const multipliers: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
1087
+ return Math.floor(parseFloat(num!) * (multipliers[unit?.toUpperCase() ?? 'B'] ?? 1));
1088
+ }
1089
+