@serfab/cadre-host 0.7.1 → 0.9.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 +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/bin/host.js +426 -112
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.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 +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.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/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- 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 +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +17 -0
- package/dist/server/error-handler.js +45 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -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 +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -3
- package/dist/server/routes/nodes.js +39 -24
- 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/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +29 -14
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- package/src/donation/__tests__/donation-service.test.ts +290 -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 +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +322 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +42 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +31 -15
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -26,13 +26,17 @@ import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
|
|
|
26
26
|
import { PortAllocator } from './port-allocator.js';
|
|
27
27
|
import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
|
|
28
28
|
import { isPidAlive } from './pid-liveness.js';
|
|
29
|
+
import type { PushCredentials } from '@serfab/cadre-core';
|
|
29
30
|
import {
|
|
30
31
|
decodeDockerId,
|
|
31
32
|
encodeDockerId,
|
|
33
|
+
type OwnerSpawnConfig,
|
|
32
34
|
type Handle,
|
|
33
35
|
type HostProcessConfig,
|
|
34
36
|
type ManagedNodeInfo,
|
|
37
|
+
type NodePorts,
|
|
35
38
|
type NodeStateListener,
|
|
39
|
+
type PushCredentialsResolver,
|
|
36
40
|
} from './types.js';
|
|
37
41
|
|
|
38
42
|
const log = debug('cadre:host:orchestrator');
|
|
@@ -49,6 +53,17 @@ const STARTUP_TOKEN_FILE = '.startup-token';
|
|
|
49
53
|
const CONFIG_FILE = 'cadre.json';
|
|
50
54
|
const LIVENESS_POLL_MS = 100;
|
|
51
55
|
|
|
56
|
+
/** Fixed friendly id for the admin's owner node. */
|
|
57
|
+
export const OWNER_CONTAINER_ID = 'owner';
|
|
58
|
+
|
|
59
|
+
/** Endpoint + bearer for the owner node's loopback admin channel (6.6). */
|
|
60
|
+
export interface OwnerAdminEndpoint {
|
|
61
|
+
/** e.g. `http://127.0.0.1:<adminPort>`. */
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
/** The child's `CADRE_STARTUP_TOKEN`, used as the admin bearer secret. */
|
|
64
|
+
token: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
/**
|
|
53
68
|
* Runs cadre nodes as plain Node child processes on the host.
|
|
54
69
|
*
|
|
@@ -89,8 +104,12 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
89
104
|
private readonly defaultMemoryLimit?: string;
|
|
90
105
|
private readonly handles = new Map<string, Handle>();
|
|
91
106
|
private readonly cliEntrypoint: string;
|
|
107
|
+
/** Resolves push credentials fresh on every spawn (see HostProcessConfig). */
|
|
108
|
+
private readonly pushResolver?: PushCredentialsResolver;
|
|
92
109
|
/** State-change listeners — invoked when a handle's alive state changes. */
|
|
93
110
|
private readonly stateListeners = new Set<NodeStateListener>();
|
|
111
|
+
/** Persisted spawn config for the owner node (re-spawn on demand). */
|
|
112
|
+
private ownerConfig?: OwnerSpawnConfig;
|
|
94
113
|
|
|
95
114
|
constructor(private readonly cfg: HostProcessConfig) {
|
|
96
115
|
this.rootDir = resolvePath(cfg.rootDir);
|
|
@@ -105,6 +124,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
105
124
|
this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
|
|
106
125
|
this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
|
|
107
126
|
this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
|
|
127
|
+
this.pushResolver = cfg.pushResolver;
|
|
108
128
|
log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
|
|
109
129
|
}
|
|
110
130
|
|
|
@@ -115,6 +135,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
115
135
|
*/
|
|
116
136
|
async init(): Promise<void> {
|
|
117
137
|
const state = this.stateStore.load();
|
|
138
|
+
this.ownerConfig = state.ownerConfig;
|
|
118
139
|
for (const persisted of state.handles) {
|
|
119
140
|
const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
|
|
120
141
|
const handle: Handle = {
|
|
@@ -127,6 +148,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
127
148
|
spawnedAt: persisted.spawnedAt,
|
|
128
149
|
partyId: persisted.partyId,
|
|
129
150
|
profile: persisted.profile,
|
|
151
|
+
...(persisted.owner ? { owner: true } : {}),
|
|
130
152
|
alive,
|
|
131
153
|
};
|
|
132
154
|
this.handles.set(handle.dockerId, handle);
|
|
@@ -134,6 +156,10 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
134
156
|
this.portAllocator.markUsed(persisted.ports.health);
|
|
135
157
|
this.portAllocator.markUsed(persisted.ports.metrics);
|
|
136
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
|
+
}
|
|
137
163
|
}
|
|
138
164
|
log('init: re-attached %d handles', state.handles.length);
|
|
139
165
|
}
|
|
@@ -192,15 +218,193 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
192
218
|
}
|
|
193
219
|
|
|
194
220
|
async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
221
|
+
const ports: NodePorts = {
|
|
222
|
+
health: this.portAllocator.allocate(),
|
|
223
|
+
metrics: this.portAllocator.allocate(),
|
|
224
|
+
p2p: this.portAllocator.allocate(),
|
|
225
|
+
admin: this.portAllocator.allocate(),
|
|
226
|
+
};
|
|
227
|
+
// A node started with pinned owner keys belongs to a FOREIGN cadre (the
|
|
228
|
+
// node-donation flow: it trusts the requester's owner key, not the host's).
|
|
229
|
+
// The host's FCM/APNs credentials are minted for the host owner's own app
|
|
230
|
+
// and are meaningless to a foreign cadre — so donated nodes get NO push
|
|
231
|
+
// block. (Per-grantee push creds would be a future ticket; none in v1.)
|
|
232
|
+
const foreignParty = (request.pinnedOwnerKeys?.length ?? 0) > 0;
|
|
233
|
+
// Only storage-profile nodes participate in strands and thus fan out push
|
|
234
|
+
// wakes — a transaction-only node need not carry credentials.
|
|
235
|
+
const push = request.profile === 'storage' && !foreignParty ? await this.resolvePush() : undefined;
|
|
236
|
+
// Pinned owner key(s) reach the child via CADRE_OWNER_KEYS (comma-separated);
|
|
237
|
+
// `cadre-cli start` unions it into its cold-start pinnedKeyTrustPolicy so the
|
|
238
|
+
// node will accept the foreign-authority-signed seed presented via POST /seed.
|
|
239
|
+
const extraEnv = request.pinnedOwnerKeys?.length
|
|
240
|
+
? { CADRE_OWNER_KEYS: request.pinnedOwnerKeys.join(',') }
|
|
241
|
+
: undefined;
|
|
242
|
+
return this.launchChild({
|
|
243
|
+
containerId: request.containerId,
|
|
244
|
+
partyId: request.partyId,
|
|
245
|
+
profile: request.profile,
|
|
246
|
+
ports,
|
|
247
|
+
owner: false,
|
|
248
|
+
buildConfig: (workdir) => this.buildChildConfig(request, workdir, push),
|
|
249
|
+
extraArgs: [],
|
|
250
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
251
|
+
...(request.resources?.memoryLimit ? { memoryLimit: request.resources.memoryLimit } : {}),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the push credentials to inject into the node about to spawn. Called
|
|
257
|
+
* on every spawn so a re-spawn re-reads the secret store — raw private keys are
|
|
258
|
+
* never persisted. A resolver failure (e.g. a partial credential set) is
|
|
259
|
+
* logged and degrades to "no push" rather than failing the spawn: the node
|
|
260
|
+
* stays up and reachable, just without platform push-wake.
|
|
261
|
+
*/
|
|
262
|
+
private async resolvePush(): Promise<PushCredentials | undefined> {
|
|
263
|
+
if (!this.pushResolver) return undefined;
|
|
264
|
+
try {
|
|
265
|
+
const push = await this.pushResolver();
|
|
266
|
+
if (push) {
|
|
267
|
+
log('resolved push credentials: fcm=%s apns=%s cooldownMs=%s', !!push.fcm, !!push.apns, push.cooldownMs);
|
|
268
|
+
}
|
|
269
|
+
return push;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
log('push resolver failed; spawning node without push: %s', (err as Error).message);
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Ensure the admin's owner node is running, spawning it if absent or
|
|
278
|
+
* dead. Idempotent: a second call with the node already alive returns the
|
|
279
|
+
* existing handle without launching a second child. The spawn parameters
|
|
280
|
+
* are persisted so a later `/api/nodes/:id/{start,restart}` (or an
|
|
281
|
+
* orchestrator restart) can re-spawn it without re-supplying them.
|
|
282
|
+
*
|
|
283
|
+
* The owner node is spawned as `cadre-cli start --owner
|
|
284
|
+
* --admin-port <p> --identity-protobuf <identityPath>` so it carries the
|
|
285
|
+
* host's libp2p identity, founds/joins the control network, and exposes the
|
|
286
|
+
* loopback admin channel the manager delegates to.
|
|
287
|
+
*/
|
|
288
|
+
async ensureOwnerNode(cfg?: OwnerSpawnConfig): Promise<ManagedNodeInfo> {
|
|
289
|
+
const config = cfg ?? this.ownerConfig;
|
|
290
|
+
if (!config) {
|
|
291
|
+
throw new Error('ensureOwnerNode: no owner config supplied and none persisted');
|
|
292
|
+
}
|
|
293
|
+
this.ownerConfig = config;
|
|
294
|
+
|
|
295
|
+
const existing = this.findOwnerHandle();
|
|
296
|
+
if (existing && isPidAlive(existing.pid) && tokenMatches(existing.workdir, existing.startupToken)) {
|
|
297
|
+
this.persist();
|
|
298
|
+
return toNodeInfo(existing);
|
|
299
|
+
}
|
|
300
|
+
if (existing) {
|
|
301
|
+
// Drop the stale handle and release its ports — but DO NOT delete the
|
|
302
|
+
// workdir: the owner node's control-DB storage lives there and must
|
|
303
|
+
// survive a restart. launchChild reuses the same workdir.
|
|
304
|
+
this.releasePorts(existing.ports);
|
|
305
|
+
this.handles.delete(existing.dockerId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const profile = config.profile ?? 'storage';
|
|
309
|
+
// Re-resolve push credentials from the secret store on every (re-)spawn so a
|
|
310
|
+
// restart picks up rotated keys and nothing raw is replayed from state.json.
|
|
311
|
+
const push = await this.resolvePush();
|
|
312
|
+
const ports: NodePorts = {
|
|
313
|
+
health: this.portAllocator.allocate(),
|
|
314
|
+
metrics: this.portAllocator.allocate(),
|
|
315
|
+
admin: this.portAllocator.allocate(),
|
|
316
|
+
// The owner node must listen on the configured libp2p port so the
|
|
317
|
+
// NAT mapping (external → internal) lands on it. That port is outside
|
|
318
|
+
// the managed range, so markUsed is a harmless no-op.
|
|
319
|
+
p2p: config.libp2pPort,
|
|
320
|
+
};
|
|
321
|
+
this.portAllocator.markUsed(config.libp2pPort);
|
|
322
|
+
|
|
323
|
+
const result = this.launchChild({
|
|
324
|
+
containerId: OWNER_CONTAINER_ID,
|
|
325
|
+
partyId: config.partyId,
|
|
326
|
+
profile,
|
|
327
|
+
ports,
|
|
328
|
+
owner: true,
|
|
329
|
+
buildConfig: (workdir) => this.buildOwnerChildConfig(config, profile, workdir, push),
|
|
330
|
+
extraArgs: ['--owner', '--admin-port', String(ports.admin), '--identity-protobuf', config.identityPath],
|
|
331
|
+
});
|
|
332
|
+
const handle = this.handles.get(result.dockerId)!;
|
|
333
|
+
return toNodeInfo(handle);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Endpoint + bearer for the owner node's admin channel, if spawned. */
|
|
337
|
+
getOwnerAdminEndpoint(): OwnerAdminEndpoint | undefined {
|
|
338
|
+
const handle = this.findOwnerHandle();
|
|
339
|
+
if (!handle) return undefined;
|
|
340
|
+
return {
|
|
341
|
+
baseUrl: `http://127.0.0.1:${handle.ports.admin}`,
|
|
342
|
+
token: handle.startupToken,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Whether spawn parameters for the owner node are known (persisted). */
|
|
347
|
+
hasOwnerConfig(): boolean {
|
|
348
|
+
return this.ownerConfig !== undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Whether the given friendly/docker id refers to the owner node. */
|
|
352
|
+
isOwnerNode(idOrDockerId: string): boolean {
|
|
353
|
+
if (idOrDockerId === OWNER_CONTAINER_ID) return true;
|
|
354
|
+
const direct = this.handles.get(idOrDockerId);
|
|
355
|
+
if (direct?.owner) return true;
|
|
356
|
+
for (const h of this.handles.values()) {
|
|
357
|
+
if (h.owner && h.containerId === idOrDockerId) return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Stop the owner node (if running). Used on graceful shutdown. */
|
|
363
|
+
async stopOwnerNode(): Promise<void> {
|
|
364
|
+
const handle = this.findOwnerHandle();
|
|
365
|
+
if (handle && isPidAlive(handle.pid)) {
|
|
366
|
+
await this.stopContainer(handle.dockerId);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
198
369
|
|
|
199
|
-
|
|
370
|
+
/** Stop the owner node (if running), then re-spawn it from saved config. */
|
|
371
|
+
async restartOwnerNode(): Promise<ManagedNodeInfo> {
|
|
372
|
+
const handle = this.findOwnerHandle();
|
|
373
|
+
if (handle && isPidAlive(handle.pid)) {
|
|
374
|
+
await this.stopContainer(handle.dockerId);
|
|
375
|
+
}
|
|
376
|
+
return this.ensureOwnerNode();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Core spawn mechanics shared by `createContainer` and `ensureOwnerNode`.
|
|
381
|
+
* The caller has already allocated `ports`. Releases the ports on a
|
|
382
|
+
* synchronous spawn failure.
|
|
383
|
+
*/
|
|
384
|
+
private launchChild(opts: {
|
|
385
|
+
containerId: string;
|
|
386
|
+
partyId: string;
|
|
387
|
+
profile: 'storage' | 'transaction';
|
|
388
|
+
ports: NodePorts;
|
|
389
|
+
owner: boolean;
|
|
390
|
+
buildConfig: (workdir: string) => Record<string, unknown>;
|
|
391
|
+
extraArgs: string[];
|
|
392
|
+
/** Extra env vars merged into the child's environment (e.g. CADRE_OWNER_KEYS). */
|
|
393
|
+
extraEnv?: Record<string, string>;
|
|
394
|
+
memoryLimit?: string;
|
|
395
|
+
}): OrchestratorCreateResult {
|
|
396
|
+
const { containerId, ports } = opts;
|
|
397
|
+
const workdir = join(this.rootDir, containerId);
|
|
200
398
|
mkdirSync(workdir, { recursive: true });
|
|
201
399
|
mkdirSync(join(workdir, 'storage'), { recursive: true });
|
|
202
400
|
|
|
203
401
|
const token = randomBytes(16).toString('hex');
|
|
402
|
+
// Per-container secret gating the node's `POST /seed` route. The node
|
|
403
|
+
// refuses seed delivery unless the caller presents this as a bearer token
|
|
404
|
+
// (and unregisters the route entirely when the env var is empty), so we
|
|
405
|
+
// mint a fresh high-entropy value per child and hand it back on the result
|
|
406
|
+
// for `ContainerService.applySeed` to send.
|
|
407
|
+
const seedToken = randomBytes(32).toString('base64url');
|
|
204
408
|
const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
|
|
205
409
|
// Ensure stale token from a previous container with the same id is cleared.
|
|
206
410
|
if (existsSync(tokenPath)) {
|
|
@@ -208,22 +412,22 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
208
412
|
}
|
|
209
413
|
|
|
210
414
|
const configPath = join(workdir, CONFIG_FILE);
|
|
211
|
-
writeFileSync(
|
|
212
|
-
configPath,
|
|
213
|
-
JSON.stringify(this.buildChildConfig(request, workdir), null, 2),
|
|
214
|
-
'utf8',
|
|
215
|
-
);
|
|
415
|
+
writeFileSync(configPath, JSON.stringify(opts.buildConfig(workdir), null, 2), 'utf8');
|
|
216
416
|
|
|
217
|
-
const memoryLimit =
|
|
417
|
+
const memoryLimit = opts.memoryLimit ?? this.defaultMemoryLimit;
|
|
218
418
|
const heapBytes = parseMemoryLimit(memoryLimit);
|
|
219
419
|
const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
|
|
220
420
|
|
|
221
421
|
const env: NodeJS.ProcessEnv = {
|
|
222
422
|
...process.env,
|
|
423
|
+
// Caller-supplied extras first — the fixed per-child vars below always win
|
|
424
|
+
// so extraEnv can never clobber the seed/startup tokens or port wiring.
|
|
425
|
+
...opts.extraEnv,
|
|
223
426
|
CADRE_STARTUP_TOKEN: token,
|
|
224
|
-
CADRE_HEALTH_PORT: String(
|
|
225
|
-
CADRE_METRICS_PORT: String(
|
|
226
|
-
CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${
|
|
427
|
+
CADRE_HEALTH_PORT: String(ports.health),
|
|
428
|
+
CADRE_METRICS_PORT: String(ports.metrics),
|
|
429
|
+
CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${ports.p2p}`,
|
|
430
|
+
CADRE_SEED_TOKEN: seedToken,
|
|
227
431
|
};
|
|
228
432
|
if (heapMB !== undefined) {
|
|
229
433
|
env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
|
|
@@ -236,35 +440,32 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
236
440
|
maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
|
|
237
441
|
const logFd = openSync(logPath, 'a');
|
|
238
442
|
|
|
443
|
+
const args = [
|
|
444
|
+
this.cliEntrypoint,
|
|
445
|
+
'start',
|
|
446
|
+
'-c', configPath,
|
|
447
|
+
'--health-port', String(ports.health),
|
|
448
|
+
'--metrics-port', String(ports.metrics),
|
|
449
|
+
'--startup-token-file', tokenPath,
|
|
450
|
+
...opts.extraArgs,
|
|
451
|
+
];
|
|
452
|
+
|
|
239
453
|
let child: ChildProcess;
|
|
240
454
|
try {
|
|
241
|
-
child = spawn(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
],
|
|
251
|
-
{
|
|
252
|
-
cwd: workdir,
|
|
253
|
-
detached: true,
|
|
254
|
-
// Inherit the log fd directly. OS duplicates it into the child;
|
|
255
|
-
// closing our copy below is safe and severs the parent's only
|
|
256
|
-
// tie to the child's write path.
|
|
257
|
-
stdio: ['ignore', logFd, logFd],
|
|
258
|
-
env,
|
|
259
|
-
},
|
|
260
|
-
);
|
|
455
|
+
child = spawn(process.execPath, args, {
|
|
456
|
+
cwd: workdir,
|
|
457
|
+
detached: true,
|
|
458
|
+
// Inherit the log fd directly. OS duplicates it into the child;
|
|
459
|
+
// closing our copy below is safe and severs the parent's only
|
|
460
|
+
// tie to the child's write path.
|
|
461
|
+
stdio: ['ignore', logFd, logFd],
|
|
462
|
+
env,
|
|
463
|
+
});
|
|
261
464
|
} catch (err) {
|
|
262
465
|
// Synchronous spawn failure — release the fd and the reserved ports
|
|
263
466
|
// before propagating, so a bad-args bug doesn't leak resources.
|
|
264
467
|
closeSync(logFd);
|
|
265
|
-
this.
|
|
266
|
-
this.portAllocator.release(metricsPort);
|
|
267
|
-
this.portAllocator.release(p2pPort);
|
|
468
|
+
this.releasePorts(ports);
|
|
268
469
|
throw err;
|
|
269
470
|
}
|
|
270
471
|
// Always release the parent's copy of the fd — the child has its own.
|
|
@@ -272,25 +473,24 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
272
473
|
|
|
273
474
|
if (!child.pid) {
|
|
274
475
|
// Spawn returned without a pid — release reservations and report.
|
|
275
|
-
this.
|
|
276
|
-
|
|
277
|
-
this.portAllocator.release(p2pPort);
|
|
278
|
-
throw new Error(`Failed to spawn child for container ${request.containerId}`);
|
|
476
|
+
this.releasePorts(ports);
|
|
477
|
+
throw new Error(`Failed to spawn child for container ${containerId}`);
|
|
279
478
|
}
|
|
280
479
|
|
|
281
480
|
child.unref();
|
|
282
481
|
|
|
283
482
|
const dockerId = encodeDockerId(child.pid, token);
|
|
284
483
|
const handle: Handle = {
|
|
285
|
-
containerId
|
|
484
|
+
containerId,
|
|
286
485
|
dockerId,
|
|
287
486
|
pid: child.pid,
|
|
288
487
|
startupToken: token,
|
|
289
488
|
workdir,
|
|
290
|
-
ports
|
|
489
|
+
ports,
|
|
291
490
|
spawnedAt: new Date().toISOString(),
|
|
292
|
-
partyId:
|
|
293
|
-
profile:
|
|
491
|
+
partyId: opts.partyId,
|
|
492
|
+
profile: opts.profile,
|
|
493
|
+
...(opts.owner ? { owner: true } : {}),
|
|
294
494
|
child,
|
|
295
495
|
alive: true,
|
|
296
496
|
};
|
|
@@ -307,16 +507,34 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
307
507
|
this.persist();
|
|
308
508
|
this.emitStateChange(handle);
|
|
309
509
|
|
|
310
|
-
log('created container %s -> pid %d ports=%j',
|
|
510
|
+
log('created container %s -> pid %d ports=%j', containerId, child.pid, handle.ports);
|
|
311
511
|
|
|
312
512
|
return {
|
|
313
513
|
dockerId,
|
|
314
|
-
healthEndpoint: `http://localhost:${
|
|
315
|
-
metricsEndpoint: `http://localhost:${
|
|
316
|
-
|
|
514
|
+
healthEndpoint: `http://localhost:${ports.health}/health`,
|
|
515
|
+
metricsEndpoint: `http://localhost:${ports.metrics}/metrics`,
|
|
516
|
+
// The node's seed API (`POST /seed`) is bound to the same server/port as `/health`.
|
|
517
|
+
seedEndpoint: `http://localhost:${ports.health}/seed`,
|
|
518
|
+
seedToken,
|
|
519
|
+
p2pPort: ports.p2p,
|
|
317
520
|
};
|
|
318
521
|
}
|
|
319
522
|
|
|
523
|
+
/** Locate the owner node's handle, if one has been spawned. */
|
|
524
|
+
private findOwnerHandle(): Handle | undefined {
|
|
525
|
+
for (const h of this.handles.values()) {
|
|
526
|
+
if (h.owner || h.containerId === OWNER_CONTAINER_ID) return h;
|
|
527
|
+
}
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private releasePorts(ports: NodePorts): void {
|
|
532
|
+
this.portAllocator.release(ports.health);
|
|
533
|
+
this.portAllocator.release(ports.metrics);
|
|
534
|
+
this.portAllocator.release(ports.p2p);
|
|
535
|
+
this.portAllocator.release(ports.admin);
|
|
536
|
+
}
|
|
537
|
+
|
|
320
538
|
async stopContainer(dockerId: string): Promise<void> {
|
|
321
539
|
const handle = this.requireHandle(dockerId);
|
|
322
540
|
if (!isPidAlive(handle.pid)) {
|
|
@@ -378,9 +596,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
378
596
|
await this.stopContainer(dockerId);
|
|
379
597
|
}
|
|
380
598
|
|
|
381
|
-
this.
|
|
382
|
-
this.portAllocator.release(handle.ports.metrics);
|
|
383
|
-
this.portAllocator.release(handle.ports.p2p);
|
|
599
|
+
this.releasePorts(handle.ports);
|
|
384
600
|
|
|
385
601
|
// On Windows the OS releases the child's cwd handle asynchronously, so
|
|
386
602
|
// retry rmSync to give the kernel a chance to drop it.
|
|
@@ -395,7 +611,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
395
611
|
}
|
|
396
612
|
|
|
397
613
|
async isRunning(dockerId: string): Promise<boolean> {
|
|
398
|
-
|
|
614
|
+
const handle: Handle | undefined = this.handles.get(dockerId);
|
|
399
615
|
let pid: number;
|
|
400
616
|
let token: string;
|
|
401
617
|
let workdir: string | undefined;
|
|
@@ -459,7 +675,11 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
459
675
|
return handle;
|
|
460
676
|
}
|
|
461
677
|
|
|
462
|
-
private buildChildConfig(
|
|
678
|
+
private buildChildConfig(
|
|
679
|
+
req: OrchestratorCreateRequest,
|
|
680
|
+
workdir: string,
|
|
681
|
+
push?: PushCredentials,
|
|
682
|
+
): Record<string, unknown> {
|
|
463
683
|
const cfg: Record<string, unknown> = {
|
|
464
684
|
controlNetwork: {
|
|
465
685
|
partyId: req.partyId,
|
|
@@ -478,13 +698,58 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
478
698
|
}
|
|
479
699
|
cfg.storage = storage;
|
|
480
700
|
}
|
|
701
|
+
// Push credentials land in the child's cadre.json on the host filesystem —
|
|
702
|
+
// the same trust boundary as the control-DB storage in this workdir. The
|
|
703
|
+
// private keys are NOT logged (the resolver redacts; we log only presence).
|
|
704
|
+
if (push) cfg.push = push;
|
|
481
705
|
return cfg;
|
|
482
706
|
}
|
|
483
707
|
|
|
708
|
+
/**
|
|
709
|
+
* Child config for the **host's own personal cadre** owner node — the opt-in
|
|
710
|
+
* "founder" persona spawned only when `ownCadre.enabled` (see
|
|
711
|
+
* docs/cadre-host.md § Two roles: donor and founder). It founds/joins the
|
|
712
|
+
* control network for the host's own `partyId` (no bootstrap peers — it is the
|
|
713
|
+
* founding node of the host's *own* cadre) and carries the host identity via
|
|
714
|
+
* `--identity-protobuf` (passed as a spawn arg).
|
|
715
|
+
*
|
|
716
|
+
* This is NOT the node cadre-host donates to a requester. Donated nodes are
|
|
717
|
+
* generic (`createContainer` / `buildChildConfig`): they join the
|
|
718
|
+
* **requester's** cadre via bootstrap peers and pin the *requester's* owner
|
|
719
|
+
* key — they never run a host genesis.
|
|
720
|
+
*/
|
|
721
|
+
private buildOwnerChildConfig(
|
|
722
|
+
cfg: OwnerSpawnConfig,
|
|
723
|
+
profile: 'storage' | 'transaction',
|
|
724
|
+
workdir: string,
|
|
725
|
+
push?: PushCredentials,
|
|
726
|
+
): Record<string, unknown> {
|
|
727
|
+
const config: Record<string, unknown> = {
|
|
728
|
+
controlNetwork: {
|
|
729
|
+
partyId: cfg.partyId,
|
|
730
|
+
bootstrapNodes: [],
|
|
731
|
+
},
|
|
732
|
+
profile,
|
|
733
|
+
strandFilter: 'all',
|
|
734
|
+
};
|
|
735
|
+
if (profile === 'storage') {
|
|
736
|
+
config.storage = { type: 'file', path: join(workdir, 'storage') };
|
|
737
|
+
}
|
|
738
|
+
// The host-own-cadre owner/storage node participates in strands, so it owns
|
|
739
|
+
// the push-wake fan-out when credentials are configured. (Donated nodes
|
|
740
|
+
// belong to foreign cadres and get NO host push block — see the donor path.)
|
|
741
|
+
// Written into cadre.json (same host trust boundary as the workdir's
|
|
742
|
+
// control-DB); keys are never logged — only the redacted presence line in
|
|
743
|
+
// resolvePush.
|
|
744
|
+
if (push) config.push = push;
|
|
745
|
+
return config;
|
|
746
|
+
}
|
|
747
|
+
|
|
484
748
|
private persist(): void {
|
|
485
749
|
const state: PersistedState = {
|
|
486
750
|
version: 1,
|
|
487
751
|
handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
|
|
752
|
+
...(this.ownerConfig ? { ownerConfig: this.ownerConfig } : {}),
|
|
488
753
|
};
|
|
489
754
|
this.stateStore.save(state);
|
|
490
755
|
}
|
|
@@ -512,6 +777,7 @@ export class HostProcessOrchestrator implements Orchestrator {
|
|
|
512
777
|
spawnedAt: h.spawnedAt,
|
|
513
778
|
partyId: h.partyId,
|
|
514
779
|
profile: h.profile,
|
|
780
|
+
...(h.owner ? { owner: true } : {}),
|
|
515
781
|
};
|
|
516
782
|
}
|
|
517
783
|
}
|
|
@@ -526,6 +792,7 @@ function toNodeInfo(h: Handle): ManagedNodeInfo {
|
|
|
526
792
|
spawnedAt: h.spawnedAt,
|
|
527
793
|
workdir: h.workdir,
|
|
528
794
|
ports: { ...h.ports },
|
|
795
|
+
...(h.owner ? { owner: true } : {}),
|
|
529
796
|
};
|
|
530
797
|
}
|
|
531
798
|
|
|
@@ -542,6 +809,7 @@ function resolveCadreCliBin(): string {
|
|
|
542
809
|
`Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
|
|
543
810
|
`Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
|
|
544
811
|
`and listed as a dependency. Underlying error: ${(err as Error).message}`,
|
|
812
|
+
{ cause: err },
|
|
545
813
|
);
|
|
546
814
|
}
|
|
547
815
|
}
|
|
@@ -564,7 +832,7 @@ function tokenMatches(workdir: string, expected: string): boolean {
|
|
|
564
832
|
*/
|
|
565
833
|
function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
|
|
566
834
|
if (maxBytes <= 0 || maxFiles < 1) return;
|
|
567
|
-
let size
|
|
835
|
+
let size: number;
|
|
568
836
|
try {
|
|
569
837
|
size = statSync(logPath).size;
|
|
570
838
|
} catch {
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
export { HostProcessOrchestrator } from './host-process-orchestrator.js';
|
|
2
|
-
export type {
|
|
1
|
+
export { HostProcessOrchestrator, OWNER_CONTAINER_ID } from './host-process-orchestrator.js';
|
|
2
|
+
export type { OwnerAdminEndpoint } from './host-process-orchestrator.js';
|
|
3
|
+
export type {
|
|
4
|
+
HostProcessConfig,
|
|
5
|
+
ManagedNodeInfo,
|
|
6
|
+
NodeStateListener,
|
|
7
|
+
NodePorts,
|
|
8
|
+
OwnerSpawnConfig,
|
|
9
|
+
} from './types.js';
|
|
3
10
|
export type { PersistedHandle } from './state-store.js';
|
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from '
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import debug from 'debug';
|
|
4
4
|
|
|
5
|
+
import type { OwnerSpawnConfig, NodePorts } from './types.js';
|
|
6
|
+
|
|
5
7
|
const log = debug('cadre:host:state-store');
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -15,15 +17,19 @@ export interface PersistedHandle {
|
|
|
15
17
|
pid: number;
|
|
16
18
|
startupToken: string;
|
|
17
19
|
workdir: string;
|
|
18
|
-
ports:
|
|
20
|
+
ports: NodePorts;
|
|
19
21
|
spawnedAt: string; // ISO timestamp
|
|
20
22
|
partyId: string;
|
|
21
23
|
profile: 'storage' | 'transaction';
|
|
24
|
+
/** True for the admin's owner node. */
|
|
25
|
+
owner?: boolean;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export interface PersistedState {
|
|
25
29
|
version: 1;
|
|
26
30
|
handles: PersistedHandle[];
|
|
31
|
+
/** Spawn parameters for the owner node, so it can be re-spawned on demand. */
|
|
32
|
+
ownerConfig?: OwnerSpawnConfig;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
const STATE_VERSION = 1;
|
|
@@ -48,7 +54,11 @@ export class StateStore {
|
|
|
48
54
|
log('state file at %s has unexpected shape; treating as empty', this.path);
|
|
49
55
|
return { version: STATE_VERSION, handles: [] };
|
|
50
56
|
}
|
|
51
|
-
return {
|
|
57
|
+
return {
|
|
58
|
+
version: STATE_VERSION,
|
|
59
|
+
handles: parsed.handles,
|
|
60
|
+
...(parsed.ownerConfig ? { ownerConfig: parsed.ownerConfig } : {}),
|
|
61
|
+
};
|
|
52
62
|
} catch (err) {
|
|
53
63
|
// A corrupt state file is operationally significant — surviving children
|
|
54
64
|
// may now be leaked to the OS without an in-memory handle. Make the
|
|
@@ -60,7 +70,15 @@ export class StateStore {
|
|
|
60
70
|
|
|
61
71
|
save(state: PersistedState): void {
|
|
62
72
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
63
|
-
const payload = JSON.stringify(
|
|
73
|
+
const payload = JSON.stringify(
|
|
74
|
+
{
|
|
75
|
+
version: STATE_VERSION,
|
|
76
|
+
handles: state.handles,
|
|
77
|
+
...(state.ownerConfig ? { ownerConfig: state.ownerConfig } : {}),
|
|
78
|
+
},
|
|
79
|
+
null,
|
|
80
|
+
2,
|
|
81
|
+
);
|
|
64
82
|
const tmp = `${this.path}.tmp`;
|
|
65
83
|
writeFileSync(tmp, payload, { encoding: 'utf8' });
|
|
66
84
|
renameSync(tmp, this.path);
|