@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.
- package/README.md +19 -4
- package/dist/bin/host.js +32 -20
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/nat-output.d.ts +5 -0
- package/dist/bin/nat-output.js +12 -1
- package/dist/bin/nat-output.js.map +1 -1
- package/dist/bin/ui-access.d.ts +22 -0
- package/dist/bin/ui-access.js +44 -0
- package/dist/bin/ui-access.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +25 -3
- package/dist/hosted/claim-details.js +31 -37
- package/dist/hosted/claim-details.js.map +1 -1
- package/dist/hosted/hosted-node-service.d.ts +7 -0
- package/dist/hosted/hosted-node-service.js +16 -4
- package/dist/hosted/hosted-node-service.js.map +1 -1
- package/dist/hosted/hosted-node-watcher.d.ts +16 -4
- package/dist/hosted/hosted-node-watcher.js +45 -9
- package/dist/hosted/hosted-node-watcher.js.map +1 -1
- package/dist/hosted/node-status.d.ts +19 -3
- package/dist/hosted/node-status.js +21 -5
- package/dist/hosted/node-status.js.map +1 -1
- package/dist/hosted/types.d.ts +12 -5
- package/dist/hosted/types.js.map +1 -1
- package/dist/installer/config.d.ts +23 -0
- package/dist/installer/config.js +46 -0
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.js +7 -16
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/wizard.d.ts +12 -2
- package/dist/installer/wizard.js +15 -7
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/index.d.ts +2 -1
- package/dist/nat/index.js +1 -0
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +22 -5
- package/dist/nat/nat-service.js +75 -26
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.js +11 -0
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/network-mode.d.ts +19 -0
- package/dist/nat/network-mode.js +32 -0
- package/dist/nat/network-mode.js.map +1 -0
- package/dist/nat/reachability.d.ts +5 -4
- package/dist/nat/reachability.js +19 -6
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +26 -6
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
- package/dist/orchestrator/host-process-orchestrator.js +6 -15
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/package-version.d.ts +1 -0
- package/dist/package-version.js +4 -0
- package/dist/package-version.js.map +1 -0
- package/dist/server/routes/settings.d.ts +1 -0
- package/dist/server/routes/settings.js +15 -1
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.js +2 -32
- package/dist/server/routes/status.js.map +1 -1
- package/dist/ui/assets/index-BHd8CWZA.css +1 -0
- package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
- package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +4 -5
- package/src/__tests__/cli.smoke.test.ts +1 -1
- package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
- package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
- package/src/__tests__/orchestrator-handle-contract.ts +1 -1
- package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
- package/src/__tests__/orchestrator-push.test.ts +3 -3
- package/src/__tests__/orchestrator.test.ts +3 -5
- package/src/__tests__/ui-access.test.ts +30 -0
- package/src/bin/host.ts +30 -20
- package/src/bin/nat-output.ts +12 -1
- package/src/bin/ui-access.ts +50 -0
- package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
- package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
- package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
- package/src/hosted/claim-details.ts +44 -37
- package/src/hosted/hosted-node-service.ts +23 -4
- package/src/hosted/hosted-node-watcher.ts +288 -250
- package/src/hosted/node-status.ts +31 -5
- package/src/hosted/types.ts +160 -153
- package/src/installer/__tests__/installer.smoke.test.ts +184 -182
- package/src/installer/__tests__/wizard.test.ts +76 -56
- package/src/installer/config.ts +66 -0
- package/src/installer/index.ts +252 -259
- package/src/installer/wizard.ts +101 -87
- package/src/nat/__tests__/nat-service.test.ts +608 -540
- package/src/nat/__tests__/nat-store.test.ts +130 -127
- package/src/nat/__tests__/network-mode.test.ts +38 -0
- package/src/nat/index.ts +91 -87
- package/src/nat/nat-service.ts +79 -26
- package/src/nat/nat-store.ts +249 -237
- package/src/nat/network-mode.ts +36 -0
- package/src/nat/reachability.ts +155 -143
- package/src/nat/types.ts +232 -209
- package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
- package/src/package-version.ts +4 -0
- package/src/server/__tests__/fakes.ts +41 -40
- package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
- package/src/server/__tests__/settings-route.test.ts +31 -0
- package/src/server/routes/settings.ts +128 -115
- package/src/server/routes/status.ts +72 -102
- package/dist/ui/assets/index-4FWuU8ye.js +0 -19
- 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
|
-
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// (
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
this.
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
return
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
+
|