@serfab/cadre-host 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/self-label.d.ts +36 -0
- package/dist/auth/self-label.js +57 -0
- package/dist/auth/self-label.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +36 -9
- package/dist/auth/trust-circle.js +40 -20
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/bin/host.js +291 -54
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
- package/dist/donation/__tests__/fake-orchestrator.js +180 -0
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
- package/dist/donation/donation-service.d.ts +361 -0
- package/dist/donation/donation-service.js +763 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +49 -0
- package/dist/donation/donation-store.js +148 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/donation-supervisor.d.ts +167 -0
- package/dist/donation/donation-supervisor.js +341 -0
- package/dist/donation/donation-supervisor.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +20 -0
- package/dist/donation/index.js +17 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +201 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +26 -0
- package/dist/installer/config.js +13 -0
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.js +3 -0
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/nat-service.d.ts +5 -5
- package/dist/nat/nat-service.js +9 -9
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/types.d.ts +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +144 -32
- package/dist/orchestrator/host-process-orchestrator.js +397 -128
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +26 -0
- package/dist/orchestrator/node-identity.js +36 -0
- package/dist/orchestrator/node-identity.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +14 -0
- package/dist/orchestrator/port-allocator.js +45 -0
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +5 -5
- package/dist/orchestrator/state-store.js +2 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +8 -8
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/owner/owner-node-client.d.ts +110 -0
- package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/server/error-handler.d.ts +22 -1
- package/dist/server/error-handler.js +64 -1
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +33 -2
- package/dist/server/index.js +43 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +182 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -4
- package/dist/server/routes/nodes.js +19 -18
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/routes/strands.d.ts +20 -0
- package/dist/server/routes/strands.js +38 -0
- package/dist/server/routes/strands.js.map +1 -0
- package/dist/strands/index.d.ts +7 -0
- package/dist/strands/index.js +6 -0
- package/dist/strands/index.js.map +1 -0
- package/dist/strands/strand-service.d.ts +50 -0
- package/dist/strands/strand-service.js +103 -0
- package/dist/strands/strand-service.js.map +1 -0
- package/dist/strands/types.d.ts +93 -0
- package/dist/strands/types.js +23 -0
- package/dist/strands/types.js.map +1 -0
- package/dist/ui/assets/index-BVoqLjyX.css +1 -0
- package/dist/ui/assets/index-C-iks41M.js +17 -0
- package/dist/ui/assets/index-C-iks41M.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/release-key.d.ts +1 -1
- package/dist/update/release-key.js +8 -6
- package/dist/update/release-key.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/build-targets.test.ts +26 -0
- package/src/__tests__/global-setup.ts +51 -0
- package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +10 -10
- package/src/__tests__/orchestrator.test.ts +387 -6
- package/src/auth/__tests__/self-label.test.ts +85 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
- package/src/auth/__tests__/trust-circle.test.ts +55 -11
- package/src/auth/index.ts +2 -0
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle.ts +54 -22
- package/src/auth/types.ts +2 -2
- package/src/bin/host.ts +1299 -1038
- package/src/donation/__tests__/donation-service.test.ts +1068 -0
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
- package/src/donation/__tests__/fake-orchestrator.ts +225 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +962 -0
- package/src/donation/donation-store.ts +171 -0
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +64 -0
- package/src/donation/types.ts +243 -0
- package/src/index.ts +51 -5
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +31 -1
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/config.ts +34 -0
- package/src/installer/index.ts +5 -0
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/nat-service.test.ts +5 -5
- package/src/nat/nat-service.ts +10 -10
- package/src/nat/types.ts +1 -1
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +401 -134
- package/src/orchestrator/index.ts +3 -3
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/port-allocator.ts +48 -0
- package/src/orchestrator/state-store.ts +7 -7
- package/src/orchestrator/types.ts +8 -8
- package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
- package/src/owner/index.ts +12 -0
- package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +83 -23
- package/src/server/__tests__/error-handler.test.ts +22 -0
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +263 -0
- package/src/server/__tests__/nodes-route.test.ts +25 -25
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/status-route.test.ts +21 -0
- package/src/server/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +67 -1
- package/src/server/events/types.ts +1 -0
- package/src/server/index.ts +75 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +210 -0
- package/src/server/routes/nodes.ts +19 -18
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/server/routes/strands.ts +54 -0
- package/src/strands/__tests__/strand-service.test.ts +165 -0
- package/src/strands/index.ts +14 -0
- package/src/strands/strand-service.ts +135 -0
- package/src/strands/types.ts +102 -0
- package/src/update/__tests__/release-key.test.ts +16 -3
- package/src/update/release-key.ts +8 -6
- package/dist/authority/authority-node-client.d.ts +0 -77
- package/dist/authority/authority-node-client.js.map +0 -1
- package/dist/authority/index.d.ts +0 -11
- package/dist/authority/index.js +0 -11
- package/dist/authority/index.js.map +0 -1
- package/dist/ui/assets/index-BlMXQPSw.js +0 -14
- package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
- package/dist/ui/assets/index-BwU5mJGF.css +0 -1
- package/src/authority/index.ts +0 -12
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
readSync,
|
|
13
13
|
closeSync,
|
|
14
14
|
} from 'node:fs';
|
|
15
|
-
import { join, resolve as resolvePath } from 'node:path';
|
|
15
|
+
import { join, resolve as resolvePath, sep } from 'node:path';
|
|
16
16
|
import debug from 'debug';
|
|
17
17
|
import pidusage from 'pidusage';
|
|
18
18
|
import type {
|
|
@@ -23,14 +23,15 @@ import type {
|
|
|
23
23
|
} from '@serfab/cadre-provider';
|
|
24
24
|
|
|
25
25
|
import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
|
|
26
|
-
import {
|
|
26
|
+
import { ensureNodeIdentity } from './node-identity.js';
|
|
27
|
+
import { allocateNodePorts, PortAllocator } from './port-allocator.js';
|
|
27
28
|
import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
|
|
28
29
|
import { isPidAlive } from './pid-liveness.js';
|
|
29
30
|
import type { PushCredentials } from '@serfab/cadre-core';
|
|
30
31
|
import {
|
|
31
32
|
decodeDockerId,
|
|
32
33
|
encodeDockerId,
|
|
33
|
-
type
|
|
34
|
+
type OwnerSpawnConfig,
|
|
34
35
|
type Handle,
|
|
35
36
|
type HostProcessConfig,
|
|
36
37
|
type ManagedNodeInfo,
|
|
@@ -53,11 +54,32 @@ const STARTUP_TOKEN_FILE = '.startup-token';
|
|
|
53
54
|
const CONFIG_FILE = 'cadre.json';
|
|
54
55
|
const LIVENESS_POLL_MS = 100;
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
export const AUTHORITY_CONTAINER_ID = 'authority';
|
|
57
|
+
const CADRE_ENV_PREFIX = 'CADRE_';
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Manager's own `process.env` with every `CADRE_*` key removed. A child node
|
|
61
|
+
* takes its whole configuration from the config file this orchestrator writes
|
|
62
|
+
* plus the vars set explicitly below, and `@serfab/cadre-cli` treats inherited
|
|
63
|
+
* `CADRE_*` vars as overrides that BEAT that config file — so inheriting them
|
|
64
|
+
* would let a var set on the manager (e.g. CADRE_PARTY_ID) silently
|
|
65
|
+
* reconfigure every spawned child. Scrubbing the whole prefix rather than a
|
|
66
|
+
* list of known keys means a new cli env var can't reintroduce the leak by
|
|
67
|
+
* being forgotten here. Non-CADRE vars (PATH, DEBUG, proxy/TLS settings, etc.)
|
|
68
|
+
* still pass through untouched.
|
|
69
|
+
*/
|
|
70
|
+
function scrubbedParentEnv(): NodeJS.ProcessEnv {
|
|
71
|
+
const env = { ...process.env };
|
|
72
|
+
for (const key of Object.keys(env)) {
|
|
73
|
+
if (key.startsWith(CADRE_ENV_PREFIX)) delete env[key];
|
|
74
|
+
}
|
|
75
|
+
return env;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fixed friendly id for the admin's owner node. */
|
|
79
|
+
export const OWNER_CONTAINER_ID = 'owner';
|
|
80
|
+
|
|
81
|
+
/** Endpoint + bearer for the owner node's loopback admin channel (6.6). */
|
|
82
|
+
export interface OwnerAdminEndpoint {
|
|
61
83
|
/** e.g. `http://127.0.0.1:<adminPort>`. */
|
|
62
84
|
baseUrl: string;
|
|
63
85
|
/** The child's `CADRE_STARTUP_TOKEN`, used as the admin bearer secret. */
|
|
@@ -108,8 +130,8 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
108
130
|
private readonly pushResolver?: PushCredentialsResolver;
|
|
109
131
|
/** State-change listeners — invoked when a handle's alive state changes. */
|
|
110
132
|
private readonly stateListeners = new Set<NodeStateListener>();
|
|
111
|
-
/** Persisted spawn config for the
|
|
112
|
-
private
|
|
133
|
+
/** Persisted spawn config for the owner node (re-spawn on demand). */
|
|
134
|
+
private ownerConfig?: OwnerSpawnConfig;
|
|
113
135
|
|
|
114
136
|
constructor(private readonly cfg: HostProcessConfig) {
|
|
115
137
|
this.rootDir = resolvePath(cfg.rootDir);
|
|
@@ -135,7 +157,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
135
157
|
*/
|
|
136
158
|
async init(): Promise<void> {
|
|
137
159
|
const state = this.stateStore.load();
|
|
138
|
-
this.
|
|
160
|
+
this.ownerConfig = state.ownerConfig;
|
|
139
161
|
for (const persisted of state.handles) {
|
|
140
162
|
const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
|
|
141
163
|
const handle: Handle = {
|
|
@@ -148,18 +170,12 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
148
170
|
spawnedAt: persisted.spawnedAt,
|
|
149
171
|
partyId: persisted.partyId,
|
|
150
172
|
profile: persisted.profile,
|
|
151
|
-
...(persisted.
|
|
173
|
+
...(persisted.owner ? { owner: true } : {}),
|
|
152
174
|
alive,
|
|
153
175
|
};
|
|
154
176
|
this.handles.set(handle.dockerId, handle);
|
|
155
177
|
// Always reserve ports — even for dead handles, until the caller cleans them up
|
|
156
|
-
this.
|
|
157
|
-
this.portAllocator.markUsed(persisted.ports.metrics);
|
|
158
|
-
this.portAllocator.markUsed(persisted.ports.p2p);
|
|
159
|
-
// `admin` is absent on handles persisted before this build.
|
|
160
|
-
if (typeof persisted.ports.admin === 'number') {
|
|
161
|
-
this.portAllocator.markUsed(persisted.ports.admin);
|
|
162
|
-
}
|
|
178
|
+
this.reservePorts(persisted.ports);
|
|
163
179
|
}
|
|
164
180
|
log('init: re-attached %d handles', state.handles.length);
|
|
165
181
|
}
|
|
@@ -206,37 +222,152 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
206
222
|
return () => { this.stateListeners.delete(listener); };
|
|
207
223
|
}
|
|
208
224
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Spawn a generic managed node — the donated-node path (the requester's cadre,
|
|
227
|
+
* pinned to the *requester's* owner key). The node is given its own protobuf
|
|
228
|
+
* identity key inside its workdir (`ensureNodeIdentity`, reused across
|
|
229
|
+
* re-spawns of the same containerId), which is what makes its peer id stable
|
|
230
|
+
* across restarts AND what makes its node-local stores durable: `cadre-cli
|
|
231
|
+
* start` opens the file-backed bootstrap-peer and trusted-owner stores only
|
|
232
|
+
* when a protobuf identity key file is configured, and puts them beside it.
|
|
233
|
+
* Terminating the loan (`removeContainer`) deletes the workdir, so the key and
|
|
234
|
+
* both stores go with it.
|
|
235
|
+
*/
|
|
236
|
+
async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
|
|
237
|
+
const workdir = this.workdirFor(request.containerId);
|
|
238
|
+
// Sampled BEFORE `ensureNodeIdentity`, which brings the directory into
|
|
239
|
+
// existence as a side effect of writing the key. Only a spawn that found
|
|
240
|
+
// nothing here may delete anything on its way out — a re-spawn is REUSING
|
|
241
|
+
// the identity key and node-local stores this directory holds, and they are
|
|
242
|
+
// the whole reason the node comes back as the same peer. `false` is
|
|
243
|
+
// therefore the safe default: a directory that exists for any reason
|
|
244
|
+
// (respawn, an earlier failed provision, an operator's file) is never
|
|
245
|
+
// touched by the unwind below.
|
|
246
|
+
const isNewWorkdir = !existsSync(workdir);
|
|
247
|
+
let dropped: Handle[] = [];
|
|
248
|
+
let ports: NodePorts | undefined;
|
|
249
|
+
try {
|
|
250
|
+
// Identity BEFORE any port is reserved: it is the one step here that can
|
|
251
|
+
// fail on its own (an unreadable or undecodable identity.key throws rather
|
|
252
|
+
// than silently re-keying), and everything past the allocation is either
|
|
253
|
+
// infallible or releases the ports itself. Allocating first would leak four
|
|
254
|
+
// ports from a bounded range on every failed provision attempt.
|
|
255
|
+
const identity = await ensureNodeIdentity(workdir);
|
|
256
|
+
log('container %s identity peerId=%s', request.containerId, identity.peerId);
|
|
257
|
+
// A node started with pinned owner keys belongs to a FOREIGN cadre (the
|
|
258
|
+
// node-donation flow: it trusts the requester's owner key, not the host's).
|
|
259
|
+
// The host's FCM/APNs credentials are minted for the host owner's own app
|
|
260
|
+
// and are meaningless to a foreign cadre — so donated nodes get NO push
|
|
261
|
+
// block. (Per-grantee push creds would be a future ticket; none in v1.)
|
|
262
|
+
const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
|
|
263
|
+
// Only storage-profile nodes participate in strands and thus fan out push
|
|
264
|
+
// wakes — a transaction-only node need not carry credentials. Resolved
|
|
265
|
+
// BEFORE the drop below: it is the last `await` on this path, which is what
|
|
266
|
+
// keeps the drop → launch window synchronous (see restoreDroppedHandles).
|
|
267
|
+
const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
|
|
268
|
+
// Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
|
|
269
|
+
// `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
|
|
270
|
+
// node will accept the foreign-authority-signed seed presented via POST /seed.
|
|
271
|
+
const extraEnv = request.pinnedOwnerKeys?.length
|
|
272
|
+
? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
|
|
273
|
+
: undefined;
|
|
274
|
+
|
|
275
|
+
// Nothing from here to the launch is `await`ed — which is what keeps
|
|
276
|
+
// `restoreDroppedHandles`'s documented precondition true even though the
|
|
277
|
+
// `try` now opens further up. Do not add an `await` past this line.
|
|
278
|
+
dropped = this.dropStaleHandle(request.containerId);
|
|
279
|
+
// NOTE: the p2p port is re-allocated per spawn, so a re-spawned donated
|
|
280
|
+
// node keeps its peer id but may announce a different port. Recoverable —
|
|
281
|
+
// it dials out to its retained bootstrap peers and republishes its own
|
|
282
|
+
// CadrePeer row once connected. If that reconnect ever proves too slow,
|
|
283
|
+
// pin the p2p port per containerId (see donated-node-respawn-core).
|
|
284
|
+
ports = allocateNodePorts(this.portAllocator);
|
|
285
|
+
return this.launchChild({
|
|
286
|
+
containerId: request.containerId,
|
|
287
|
+
partyId: request.partyId,
|
|
288
|
+
profile: request.profile,
|
|
289
|
+
ports,
|
|
290
|
+
owner: false,
|
|
291
|
+
buildConfig: () => this.buildChildConfig(request, workdir, push),
|
|
292
|
+
extraArgs: ['--identity-protobuf', identity.path],
|
|
293
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
294
|
+
...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
|
|
295
|
+
});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// Release BEFORE restoring: a re-spawn allocates the very ports the drop
|
|
298
|
+
// just freed, so restoring first and releasing second would hand the
|
|
299
|
+
// restored handle's own ports straight back to the allocator.
|
|
300
|
+
if (ports) this.releasePorts(ports);
|
|
301
|
+
// A no-op for the failures that happen before the drop (`dropped` is
|
|
302
|
+
// still empty).
|
|
303
|
+
this.restoreDroppedHandles(dropped);
|
|
304
|
+
// Safe because a throw out of `launchChild` means NO child is running:
|
|
305
|
+
// its throw surface ends where the child is spawned and its handle stored
|
|
306
|
+
// (everything past that line is best-effort), and the `if (!child.pid)`
|
|
307
|
+
// throw is on the no-process side too. So nothing is ever holding the
|
|
308
|
+
// directory this deletes.
|
|
309
|
+
if (isNewWorkdir) this.discardWorkdir(workdir);
|
|
310
|
+
throw err;
|
|
216
311
|
}
|
|
217
|
-
return dead;
|
|
218
312
|
}
|
|
219
313
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Remove the working directory a spawn created for `containerId` when no
|
|
316
|
+
* handle owns it — the cleanup of last resort for a donation record that
|
|
317
|
+
* never got a `dockerId` (the host died between `ensureNodeIdentity` and the
|
|
318
|
+
* handle write, so `createContainer`'s own unwind never ran). Returns whether
|
|
319
|
+
* anything was removed.
|
|
320
|
+
*
|
|
321
|
+
* Synchronous, mirroring {@link resolveDockerId}: nothing on this path needs
|
|
322
|
+
* to await.
|
|
323
|
+
*/
|
|
324
|
+
reclaimWorkdir(containerId: string): boolean {
|
|
325
|
+
// The admin's own node is never reclaimed by this route: its workdir is a
|
|
326
|
+
// single fixed path reused by every restart and it holds the host's own
|
|
327
|
+
// control-DB storage.
|
|
328
|
+
if (containerId === OWNER_CONTAINER_ID) return false;
|
|
329
|
+
// A live handle owns that directory, and `removeContainer` — which stops
|
|
330
|
+
// the child first — is the only thing allowed to delete it.
|
|
331
|
+
if (this.resolveDockerId(containerId)) return false;
|
|
332
|
+
const workdir = this.workdirFor(containerId);
|
|
333
|
+
// Defence, not a live case: donation ids are `grn_<base64url>` and so can
|
|
334
|
+
// hold neither a path separator nor a dot. A caller that ever passes
|
|
335
|
+
// something else must not be able to walk out of `rootDir`.
|
|
336
|
+
if (!workdir.startsWith(this.rootDir + sep)) {
|
|
337
|
+
log('refusing to reclaim workdir outside rootDir: %s', workdir);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
// Must be a DIRECTORY, not merely something that exists: `rootDir` also
|
|
341
|
+
// holds `state.json` (this orchestrator's own persisted handles), and
|
|
342
|
+
// `discardWorkdir`'s `rmSync` deletes files as happily as trees. Same
|
|
343
|
+
// defence class as the escape check above — no real containerId names a
|
|
344
|
+
// file — but the blast radius if one ever did is the whole handle map.
|
|
345
|
+
if (!statSync(workdir, { throwIfNoEntry: false })?.isDirectory()) return false;
|
|
346
|
+
this.discardWorkdir(workdir);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Best-effort removal of a workdir no handle owns. Logs, never throws: every
|
|
352
|
+
* caller is either unwinding a failure it must report instead of this one, or
|
|
353
|
+
* tearing a container down along a path where a surviving directory is a
|
|
354
|
+
* nuisance rather than a fault.
|
|
355
|
+
*
|
|
356
|
+
* `retries` sizes the wait for the OS to release a cwd handle — Windows drops
|
|
357
|
+
* it asynchronously after a child exits. `createContainer`'s unwind needs none
|
|
358
|
+
* of that (its throw surface ends before any child is spawned), but
|
|
359
|
+
* `reclaimWorkdir` can still meet an orphan process the previous host left
|
|
360
|
+
* running with this directory as its cwd, so it is not a *guaranteed*
|
|
361
|
+
* no-process path either — it simply logs and leaves the directory when the
|
|
362
|
+
* removal fails.
|
|
363
|
+
*/
|
|
364
|
+
private discardWorkdir(workdir: string, retries = { maxRetries: 5, retryDelay: 100 }): void {
|
|
365
|
+
try {
|
|
366
|
+
rmSync(workdir, { recursive: true, force: true, ...retries });
|
|
367
|
+
log('discarded workdir %s', workdir);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
log('failed to discard workdir %s: %o', workdir, err);
|
|
370
|
+
}
|
|
240
371
|
}
|
|
241
372
|
|
|
242
373
|
/**
|
|
@@ -261,68 +392,74 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
261
392
|
}
|
|
262
393
|
|
|
263
394
|
/**
|
|
264
|
-
* Ensure the admin's
|
|
395
|
+
* Ensure the admin's owner node is running, spawning it if absent or
|
|
265
396
|
* dead. Idempotent: a second call with the node already alive returns the
|
|
266
397
|
* existing handle without launching a second child. The spawn parameters
|
|
267
398
|
* are persisted so a later `/api/nodes/:id/{start,restart}` (or an
|
|
268
399
|
* orchestrator restart) can re-spawn it without re-supplying them.
|
|
269
400
|
*
|
|
270
|
-
* The
|
|
401
|
+
* The owner node is spawned as `cadre-cli start --owner
|
|
271
402
|
* --admin-port <p> --identity-protobuf <identityPath>` so it carries the
|
|
272
403
|
* host's libp2p identity, founds/joins the control network, and exposes the
|
|
273
404
|
* loopback admin channel the manager delegates to.
|
|
274
405
|
*/
|
|
275
|
-
async
|
|
276
|
-
const config = cfg ?? this.
|
|
406
|
+
async ensureOwnerNode(cfg?: OwnerSpawnConfig): Promise<ManagedNodeInfo> {
|
|
407
|
+
const config = cfg ?? this.ownerConfig;
|
|
277
408
|
if (!config) {
|
|
278
|
-
throw new Error('
|
|
409
|
+
throw new Error('ensureOwnerNode: no owner config supplied and none persisted');
|
|
279
410
|
}
|
|
280
|
-
this.
|
|
411
|
+
this.ownerConfig = config;
|
|
281
412
|
|
|
282
|
-
const existing = this.
|
|
413
|
+
const existing = this.findOwnerHandle();
|
|
283
414
|
if (existing && isPidAlive(existing.pid) && tokenMatches(existing.workdir, existing.startupToken)) {
|
|
284
415
|
this.persist();
|
|
285
416
|
return toNodeInfo(existing);
|
|
286
417
|
}
|
|
287
|
-
if (existing) {
|
|
288
|
-
// Drop the stale handle and release its ports — but DO NOT delete the
|
|
289
|
-
// workdir: the authority node's control-DB storage lives there and must
|
|
290
|
-
// survive a restart. launchChild reuses the same workdir.
|
|
291
|
-
this.releasePorts(existing.ports);
|
|
292
|
-
this.handles.delete(existing.dockerId);
|
|
293
|
-
}
|
|
294
418
|
|
|
295
419
|
const profile = config.profile ?? 'storage';
|
|
296
420
|
// Re-resolve push credentials from the secret store on every (re-)spawn so a
|
|
297
421
|
// restart picks up rotated keys and nothing raw is replayed from state.json.
|
|
422
|
+
// Resolved BEFORE the drop below so the drop → launch window stays
|
|
423
|
+
// synchronous (see restoreDroppedHandles).
|
|
298
424
|
const push = await this.resolvePush();
|
|
299
|
-
const ports: NodePorts = {
|
|
300
|
-
health: this.portAllocator.allocate(),
|
|
301
|
-
metrics: this.portAllocator.allocate(),
|
|
302
|
-
admin: this.portAllocator.allocate(),
|
|
303
|
-
// The authority node must listen on the configured libp2p port so the
|
|
304
|
-
// NAT mapping (external → internal) lands on it. That port is outside
|
|
305
|
-
// the managed range, so markUsed is a harmless no-op.
|
|
306
|
-
p2p: config.libp2pPort,
|
|
307
|
-
};
|
|
308
|
-
this.portAllocator.markUsed(config.libp2pPort);
|
|
309
425
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
426
|
+
// NOTE: the short-circuit above matches via `findOwnerHandle` (owner flag OR
|
|
427
|
+
// containerId), this drop only by containerId. Equivalent today — every
|
|
428
|
+
// owner handle is launched with OWNER_CONTAINER_ID. If an owner spawn ever
|
|
429
|
+
// takes a different containerId, the two would disagree and the handle the
|
|
430
|
+
// short-circuit found would survive here, leaking it and its ports.
|
|
431
|
+
const dropped = this.dropStaleHandle(OWNER_CONTAINER_ID);
|
|
432
|
+
let ports: NodePorts | undefined;
|
|
433
|
+
try {
|
|
434
|
+
// The owner node must listen on the configured libp2p port so the NAT
|
|
435
|
+
// mapping (external → internal) lands on it — hence the p2p override.
|
|
436
|
+
ports = allocateNodePorts(this.portAllocator, { p2p: config.libp2pPort });
|
|
437
|
+
const result = this.launchChild({
|
|
438
|
+
containerId: OWNER_CONTAINER_ID,
|
|
439
|
+
partyId: config.partyId,
|
|
440
|
+
profile,
|
|
441
|
+
ports,
|
|
442
|
+
owner: true,
|
|
443
|
+
buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
|
|
444
|
+
extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
|
|
445
|
+
});
|
|
446
|
+
const handle = this.handles.get(result.dockerId)!;
|
|
447
|
+
return toNodeInfo(handle);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
// Release before restoring — see the same note in createContainer.
|
|
450
|
+
if (ports) this.releasePorts(ports);
|
|
451
|
+
this.restoreDroppedHandles(dropped);
|
|
452
|
+
// Deliberately NOT `discardWorkdir` — the asymmetry with createContainer
|
|
453
|
+
// is the point. `<rootDir>/owner` is a single fixed path reused by every
|
|
454
|
+
// restart and holds the host's own control-DB storage, so it neither
|
|
455
|
+
// grows without bound nor is safe to delete on a failed spawn.
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
321
458
|
}
|
|
322
459
|
|
|
323
|
-
/** Endpoint + bearer for the
|
|
324
|
-
|
|
325
|
-
const handle = this.
|
|
460
|
+
/** Endpoint + bearer for the owner node's admin channel, if spawned. */
|
|
461
|
+
getOwnerAdminEndpoint(): OwnerAdminEndpoint | undefined {
|
|
462
|
+
const handle = this.findOwnerHandle();
|
|
326
463
|
if (!handle) return undefined;
|
|
327
464
|
return {
|
|
328
465
|
baseUrl: `http://127.0.0.1:${handle.ports.admin}`,
|
|
@@ -330,56 +467,68 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
330
467
|
};
|
|
331
468
|
}
|
|
332
469
|
|
|
333
|
-
/** Whether spawn parameters for the
|
|
334
|
-
|
|
335
|
-
return this.
|
|
470
|
+
/** Whether spawn parameters for the owner node are known (persisted). */
|
|
471
|
+
hasOwnerConfig(): boolean {
|
|
472
|
+
return this.ownerConfig !== undefined;
|
|
336
473
|
}
|
|
337
474
|
|
|
338
|
-
/** Whether the given friendly/docker id refers to the
|
|
339
|
-
|
|
340
|
-
if (idOrDockerId ===
|
|
475
|
+
/** Whether the given friendly/docker id refers to the owner node. */
|
|
476
|
+
isOwnerNode(idOrDockerId: string): boolean {
|
|
477
|
+
if (idOrDockerId === OWNER_CONTAINER_ID) return true;
|
|
341
478
|
const direct = this.handles.get(idOrDockerId);
|
|
342
|
-
if (direct?.
|
|
479
|
+
if (direct?.owner) return true;
|
|
343
480
|
for (const h of this.handles.values()) {
|
|
344
|
-
if (h.
|
|
481
|
+
if (h.owner && h.containerId === idOrDockerId) return true;
|
|
345
482
|
}
|
|
346
483
|
return false;
|
|
347
484
|
}
|
|
348
485
|
|
|
349
|
-
/** Stop the
|
|
350
|
-
async
|
|
351
|
-
const handle = this.
|
|
486
|
+
/** Stop the owner node (if running). Used on graceful shutdown. */
|
|
487
|
+
async stopOwnerNode(): Promise<void> {
|
|
488
|
+
const handle = this.findOwnerHandle();
|
|
352
489
|
if (handle && isPidAlive(handle.pid)) {
|
|
353
490
|
await this.stopContainer(handle.dockerId);
|
|
354
491
|
}
|
|
355
492
|
}
|
|
356
493
|
|
|
357
|
-
/** Stop the
|
|
358
|
-
async
|
|
359
|
-
const handle = this.
|
|
494
|
+
/** Stop the owner node (if running), then re-spawn it from saved config. */
|
|
495
|
+
async restartOwnerNode(): Promise<ManagedNodeInfo> {
|
|
496
|
+
const handle = this.findOwnerHandle();
|
|
360
497
|
if (handle && isPidAlive(handle.pid)) {
|
|
361
498
|
await this.stopContainer(handle.dockerId);
|
|
362
499
|
}
|
|
363
|
-
return this.
|
|
500
|
+
return this.ensureOwnerNode();
|
|
364
501
|
}
|
|
365
502
|
|
|
366
503
|
/**
|
|
367
|
-
* Core spawn mechanics shared by `createContainer` and `
|
|
368
|
-
*
|
|
369
|
-
*
|
|
504
|
+
* Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
|
|
505
|
+
*
|
|
506
|
+
* The caller has already allocated `ports` and **owns their lifetime**: this
|
|
507
|
+
* method never releases them, so every caller must release on any throw out
|
|
508
|
+
* of here (it has to unwind its handle drop on the same path anyway — see
|
|
509
|
+
* `restoreDroppedHandles`).
|
|
510
|
+
*
|
|
511
|
+
* That contract holds because this method throws only *before* the child is
|
|
512
|
+
* spawned and its handle stored — everything after that point is
|
|
513
|
+
* best-effort. Anything new added at the tail must keep it that way.
|
|
514
|
+
*
|
|
515
|
+
* Entirely synchronous, which is what makes that unwind sound. Do not add an
|
|
516
|
+
* `await` here.
|
|
370
517
|
*/
|
|
371
518
|
private launchChild(opts: {
|
|
372
519
|
containerId: string;
|
|
373
520
|
partyId: string;
|
|
374
521
|
profile: 'storage' | 'transaction';
|
|
375
522
|
ports: NodePorts;
|
|
376
|
-
|
|
523
|
+
owner: boolean;
|
|
377
524
|
buildConfig: (workdir: string) => Record<string, unknown>;
|
|
378
525
|
extraArgs: string[];
|
|
526
|
+
/** Extra env vars merged into the child's environment (e.g. CADRE_OWNER_KEYS). */
|
|
527
|
+
extraEnv?: Record<string, string>;
|
|
379
528
|
memoryLimit?: string;
|
|
380
529
|
}): OrchestratorCreateResult {
|
|
381
530
|
const { containerId, ports } = opts;
|
|
382
|
-
const workdir =
|
|
531
|
+
const workdir = this.workdirFor(containerId);
|
|
383
532
|
mkdirSync(workdir, { recursive: true });
|
|
384
533
|
mkdirSync(join(workdir, 'storage'), { recursive: true });
|
|
385
534
|
|
|
@@ -404,12 +553,23 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
404
553
|
const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
|
|
405
554
|
|
|
406
555
|
const env: NodeJS.ProcessEnv = {
|
|
407
|
-
|
|
556
|
+
// Scrubbed, not raw process.env: every CADRE_* config-override key is
|
|
557
|
+
// stripped so a var set on the manager can't silently reconfigure every
|
|
558
|
+
// child (see scrubbedParentEnv). Caller-supplied extras come next — the
|
|
559
|
+
// fixed per-child vars below always win so extraEnv can never clobber
|
|
560
|
+
// the seed/startup tokens or port wiring.
|
|
561
|
+
...scrubbedParentEnv(),
|
|
562
|
+
...opts.extraEnv,
|
|
408
563
|
CADRE_STARTUP_TOKEN: token,
|
|
409
564
|
CADRE_HEALTH_PORT: String(ports.health),
|
|
410
565
|
CADRE_METRICS_PORT: String(ports.metrics),
|
|
411
566
|
CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
|
|
412
567
|
CADRE_SEED_TOKEN: seedToken,
|
|
568
|
+
// Pin each child's node-local state (trusted-owner anchor, retained
|
|
569
|
+
// cold-start dial targets) to its OWN workdir. This is the same value the
|
|
570
|
+
// cli would derive by default (the config file lives here too), but stated
|
|
571
|
+
// explicitly rather than relying on the scrub above (belt and suspenders).
|
|
572
|
+
CADRE_NODE_STATE_DIR: workdir,
|
|
413
573
|
};
|
|
414
574
|
if (heapMB !== undefined) {
|
|
415
575
|
env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
|
|
@@ -444,18 +604,17 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
444
604
|
env,
|
|
445
605
|
});
|
|
446
606
|
} catch (err) {
|
|
447
|
-
// Synchronous spawn failure —
|
|
448
|
-
//
|
|
607
|
+
// Synchronous spawn failure — close the fd before propagating. This is
|
|
608
|
+
// the ONLY fd-leak guard on this path: nothing between the `openSync`
|
|
609
|
+
// above and the `spawn` can throw, so no other catch is needed. Keep it
|
|
610
|
+
// that way, or add one. (The ports are the caller's to release.)
|
|
449
611
|
closeSync(logFd);
|
|
450
|
-
this.releasePorts(ports);
|
|
451
612
|
throw err;
|
|
452
613
|
}
|
|
453
614
|
// Always release the parent's copy of the fd — the child has its own.
|
|
454
615
|
closeSync(logFd);
|
|
455
616
|
|
|
456
617
|
if (!child.pid) {
|
|
457
|
-
// Spawn returned without a pid — release reservations and report.
|
|
458
|
-
this.releasePorts(ports);
|
|
459
618
|
throw new Error(`Failed to spawn child for container ${containerId}`);
|
|
460
619
|
}
|
|
461
620
|
|
|
@@ -472,7 +631,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
472
631
|
spawnedAt: new Date().toISOString(),
|
|
473
632
|
partyId: opts.partyId,
|
|
474
633
|
profile: opts.profile,
|
|
475
|
-
...(opts.
|
|
634
|
+
...(opts.owner ? { owner: true } : {}),
|
|
476
635
|
child,
|
|
477
636
|
alive: true,
|
|
478
637
|
};
|
|
@@ -486,7 +645,19 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
486
645
|
});
|
|
487
646
|
|
|
488
647
|
this.handles.set(dockerId, handle);
|
|
489
|
-
this
|
|
648
|
+
// Past this line the launch has SUCCEEDED: the child is running and this
|
|
649
|
+
// handle owns its ports. So the state mirror is best-effort — a throw here
|
|
650
|
+
// would reach the caller's unwind, which would release ports the live child
|
|
651
|
+
// is bound to and restore the handle this spawn just replaced, leaving two
|
|
652
|
+
// handles for one container and one of them holding unreserved ports. A
|
|
653
|
+
// lost write self-heals: the next persist() (any stop/remove/spawn)
|
|
654
|
+
// rewrites the whole map. Until then a manager restart would fail to
|
|
655
|
+
// re-attach this child, hence the log.
|
|
656
|
+
try {
|
|
657
|
+
this.persist();
|
|
658
|
+
} catch (err) {
|
|
659
|
+
log('failed to persist state after launching container %s: %o', containerId, err);
|
|
660
|
+
}
|
|
490
661
|
this.emitStateChange(handle);
|
|
491
662
|
|
|
492
663
|
log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
|
|
@@ -502,14 +673,103 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
502
673
|
};
|
|
503
674
|
}
|
|
504
675
|
|
|
505
|
-
/**
|
|
506
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Drop any handle left over from a previous spawn of the same `containerId`
|
|
678
|
+
* and release its ports. Handles are keyed by the per-spawn `dockerId`, so
|
|
679
|
+
* without this a re-spawn (donated-node respawn) would strand the prior
|
|
680
|
+
* handle in the map forever, leaking four ports from a bounded range each
|
|
681
|
+
* time. Mirrors the same cleanup in `ensureOwnerNode`.
|
|
682
|
+
*
|
|
683
|
+
* DO NOT delete the workdir: the identity key and node-local stores
|
|
684
|
+
* (trusted-owner anchor, retained cold-start dial targets) live there and are
|
|
685
|
+
* the whole reason a respawn comes back as the same node. `launchChild`
|
|
686
|
+
* reuses the same workdir.
|
|
687
|
+
*
|
|
688
|
+
* NOTE: releasing the ports hands them straight back to the allocator, so a
|
|
689
|
+
* re-spawn while the *previous* child is still listening can bind-clash. Every
|
|
690
|
+
* caller today re-spawns only a container it has established is not running
|
|
691
|
+
* (`DonationService.respawn`, `ensureOwnerNode`). If a caller ever needs to
|
|
692
|
+
* replace a live child, stop it first — or hold the ports until its exit.
|
|
693
|
+
*
|
|
694
|
+
* **The drop is reversible.** It happens before the launch can fail, so the
|
|
695
|
+
* dropped handles are returned and every caller restores them via
|
|
696
|
+
* {@link restoreDroppedHandles} when the launch throws — otherwise the caller
|
|
697
|
+
* would be left holding a dockerId this orchestrator no longer knows, and the
|
|
698
|
+
* node's workdir could never be reclaimed. On a *successful* spawn the drop
|
|
699
|
+
* stands: `DonationService.abandonRespawn` depends on the old handle being
|
|
700
|
+
* gone by then.
|
|
701
|
+
*/
|
|
702
|
+
private dropStaleHandle(containerId: string): Handle[] {
|
|
703
|
+
const dropped: Handle[] = [];
|
|
507
704
|
for (const h of this.handles.values()) {
|
|
508
|
-
if (h.
|
|
705
|
+
if (h.containerId !== containerId) continue;
|
|
706
|
+
log('dropping stale handle %s for container %s', h.dockerId, containerId);
|
|
707
|
+
this.releasePorts(h.ports);
|
|
708
|
+
this.handles.delete(h.dockerId);
|
|
709
|
+
dropped.push(h);
|
|
710
|
+
}
|
|
711
|
+
return dropped;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Inverse of {@link dropStaleHandle}, for a spawn that then failed: put each
|
|
716
|
+
* handle back in the map and re-reserve its four ports, leaving host state
|
|
717
|
+
* exactly as the failed attempt found it.
|
|
718
|
+
*
|
|
719
|
+
* **Precondition: the drop → launch window is synchronous.** Restoring is
|
|
720
|
+
* sound only because nothing can have taken those ports or run cleanup
|
|
721
|
+
* against the handle in between — `launchChild` contains no `await` and both
|
|
722
|
+
* callers resolve push credentials *before* their drop. `launchChild`'s half
|
|
723
|
+
* is enforced: its declared return type is not a Promise, so adding an
|
|
724
|
+
* `await` there is a compile error. The callers' half is not — an `await`
|
|
725
|
+
* slipped between a drop and its launch silently breaks this.
|
|
726
|
+
*
|
|
727
|
+
* No `persist()` here: `state.json` is rewritten only by `launchChild` and
|
|
728
|
+
* only *after* the new handle is stored, so throughout the failure window the
|
|
729
|
+
* on-disk state still lists the dropped handle — this restore realigns memory
|
|
730
|
+
* to disk rather than diverging from it.
|
|
731
|
+
*
|
|
732
|
+
* NOTE: a restored handle keeps `alive: true` even if its child exited while
|
|
733
|
+
* it was out of the map (`launchChild`'s exit listener looks the handle up by
|
|
734
|
+
* dockerId and finds nothing). Only reachable by re-spawning a container
|
|
735
|
+
* whose child is still live, which `dropStaleHandle`'s note above already
|
|
736
|
+
* forbids.
|
|
737
|
+
*/
|
|
738
|
+
private restoreDroppedHandles(dropped: Handle[]): void {
|
|
739
|
+
for (const h of dropped) {
|
|
740
|
+
log('restoring dropped handle %s for container %s after failed launch', h.dockerId, h.containerId);
|
|
741
|
+
this.handles.set(h.dockerId, h);
|
|
742
|
+
this.reservePorts(h.ports);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A managed node's own directory under `rootDir` — config, log, storage, identity. */
|
|
747
|
+
private workdirFor(containerId: string): string {
|
|
748
|
+
return join(this.rootDir, containerId);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Locate the owner node's handle, if one has been spawned. */
|
|
752
|
+
private findOwnerHandle(): Handle | undefined {
|
|
753
|
+
for (const h of this.handles.values()) {
|
|
754
|
+
if (h.owner || h.containerId === OWNER_CONTAINER_ID) return h;
|
|
509
755
|
}
|
|
510
756
|
return undefined;
|
|
511
757
|
}
|
|
512
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Inverse of {@link releasePorts} — hold a handle's whole port set against
|
|
761
|
+
* the allocator. Used both to rehydrate on `init` and to undo a drop whose
|
|
762
|
+
* launch then failed.
|
|
763
|
+
*/
|
|
764
|
+
private reservePorts(ports: NodePorts): void {
|
|
765
|
+
this.portAllocator.markUsed(ports.health);
|
|
766
|
+
this.portAllocator.markUsed(ports.metrics);
|
|
767
|
+
this.portAllocator.markUsed(ports.p2p);
|
|
768
|
+
// `admin` is absent on handles persisted before this build; markUsed would
|
|
769
|
+
// otherwise put `undefined` in the used-set.
|
|
770
|
+
if (typeof ports.admin === 'number') this.portAllocator.markUsed(ports.admin);
|
|
771
|
+
}
|
|
772
|
+
|
|
513
773
|
private releasePorts(ports: NodePorts): void {
|
|
514
774
|
this.portAllocator.release(ports.health);
|
|
515
775
|
this.portAllocator.release(ports.metrics);
|
|
@@ -580,13 +840,10 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
580
840
|
|
|
581
841
|
this.releasePorts(handle.ports);
|
|
582
842
|
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
} catch (err) {
|
|
588
|
-
log('failed to remove workdir %s: %o', handle.workdir, err);
|
|
589
|
-
}
|
|
843
|
+
// Longer retries than the other callers': this is the one path that has
|
|
844
|
+
// just killed a child which held this directory as its cwd, and on Windows
|
|
845
|
+
// the OS releases that handle asynchronously.
|
|
846
|
+
this.discardWorkdir(handle.workdir, { maxRetries: 20, retryDelay: 200 });
|
|
590
847
|
|
|
591
848
|
this.handles.delete(dockerId);
|
|
592
849
|
this.persist();
|
|
@@ -688,12 +945,20 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
688
945
|
}
|
|
689
946
|
|
|
690
947
|
/**
|
|
691
|
-
* Child config for the
|
|
692
|
-
*
|
|
693
|
-
*
|
|
948
|
+
* Child config for the **host's own personal cadre** owner node — the opt-in
|
|
949
|
+
* "founder" persona spawned only when `ownCadre.enabled` (see
|
|
950
|
+
* docs/cadre-host.md § Two roles: donor and founder). It founds/joins the
|
|
951
|
+
* control network for the host's own `partyId` (no bootstrap peers — it is the
|
|
952
|
+
* founding node of the host's *own* cadre) and carries the host identity via
|
|
953
|
+
* `--identity-protobuf` (passed as a spawn arg).
|
|
954
|
+
*
|
|
955
|
+
* This is NOT the node cadre-host donates to a requester. Donated nodes are
|
|
956
|
+
* generic (`createContainer` / `buildChildConfig`): they join the
|
|
957
|
+
* **requester's** cadre via bootstrap peers and pin the *requester's* owner
|
|
958
|
+
* key — they never run a host genesis.
|
|
694
959
|
*/
|
|
695
|
-
private
|
|
696
|
-
cfg:
|
|
960
|
+
private buildOwnerChildConfig(
|
|
961
|
+
cfg: OwnerSpawnConfig,
|
|
697
962
|
profile: 'storage' | 'transaction',
|
|
698
963
|
workdir: string,
|
|
699
964
|
push?: PushCredentials,
|
|
@@ -709,10 +974,12 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
709
974
|
if (profile === 'storage') {
|
|
710
975
|
config.storage = { type: 'file', path: join(workdir, 'storage') };
|
|
711
976
|
}
|
|
712
|
-
// The
|
|
713
|
-
// the push-wake fan-out when credentials are configured.
|
|
714
|
-
//
|
|
715
|
-
//
|
|
977
|
+
// The host-own-cadre owner/storage node participates in strands, so it owns
|
|
978
|
+
// the push-wake fan-out when credentials are configured. (Donated nodes
|
|
979
|
+
// belong to foreign cadres and get NO host push block — see the donor path.)
|
|
980
|
+
// Written into cadre.json (same host trust boundary as the workdir's
|
|
981
|
+
// control-DB); keys are never logged — only the redacted presence line in
|
|
982
|
+
// resolvePush.
|
|
716
983
|
if (push) config.push = push;
|
|
717
984
|
return config;
|
|
718
985
|
}
|
|
@@ -721,7 +988,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
721
988
|
const state: PersistedState = {
|
|
722
989
|
version: 1,
|
|
723
990
|
handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
|
|
724
|
-
...(this.
|
|
991
|
+
...(this.ownerConfig ? { ownerConfig: this.ownerConfig } : {}),
|
|
725
992
|
};
|
|
726
993
|
this.stateStore.save(state);
|
|
727
994
|
}
|
|
@@ -749,7 +1016,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
749
1016
|
spawnedAt: h.spawnedAt,
|
|
750
1017
|
partyId: h.partyId,
|
|
751
1018
|
profile: h.profile,
|
|
752
|
-
...(h.
|
|
1019
|
+
...(h.owner ? { owner: true } : {}),
|
|
753
1020
|
};
|
|
754
1021
|
}
|
|
755
1022
|
}
|
|
@@ -764,7 +1031,7 @@ function toNodeInfo(h: Handle): ManagedNodeInfo {
|
|
|
764
1031
|
spawnedAt: h.spawnedAt,
|
|
765
1032
|
workdir: h.workdir,
|
|
766
1033
|
ports: { ...h.ports },
|
|
767
|
-
...(h.
|
|
1034
|
+
...(h.owner ? { owner: true } : {}),
|
|
768
1035
|
};
|
|
769
1036
|
}
|
|
770
1037
|
|