@serfab/cadre-host 0.10.0 → 0.11.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/server/static.js +38 -38
- package/dist/ui/assets/index-C-iks41M.js.map +1 -1
- package/dist/ui/index.html +12 -12
- package/package.json +4 -4
- package/service/cadre-host.service.tmpl +16 -16
- package/service/com.serfab.cadre-host.plist.tmpl +35 -35
- package/service/install-service.ps1 +98 -98
- package/service/uninstall-service.ps1 +19 -19
- package/src/__tests__/build-targets.test.ts +26 -26
- package/src/__tests__/cli-invite.smoke.test.ts +192 -192
- package/src/__tests__/cli-nat.smoke.test.ts +212 -212
- package/src/__tests__/cli.smoke.test.ts +46 -46
- package/src/__tests__/global-setup.ts +51 -51
- package/src/__tests__/host-owner.smoke.test.ts +123 -123
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
- package/src/__tests__/orchestrator-owner.test.ts +198 -198
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
- package/src/__tests__/orchestrator-push.test.ts +145 -145
- package/src/__tests__/orchestrator.test.ts +951 -951
- package/src/auth/__tests__/duration.test.ts +28 -28
- package/src/auth/__tests__/self-label.test.ts +85 -85
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
- package/src/auth/__tests__/trust-circle.test.ts +445 -445
- package/src/auth/duration.ts +33 -33
- package/src/auth/index.ts +21 -21
- package/src/auth/self-label.ts +74 -74
- package/src/auth/trust-circle-store.ts +155 -155
- package/src/auth/types.ts +99 -99
- package/src/donation/__tests__/donation-service.test.ts +1068 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +225 -225
- package/src/donation/__tests__/grant-service.test.ts +213 -213
- package/src/donation/__tests__/grant-store.test.ts +113 -113
- package/src/donation/donation-service.ts +962 -962
- package/src/donation/donation-store.ts +171 -171
- package/src/donation/donation-supervisor.ts +388 -388
- package/src/donation/grant-service.ts +200 -200
- package/src/donation/grant-store.ts +152 -152
- package/src/donation/index.ts +64 -64
- package/src/donation/types.ts +243 -243
- package/src/installer/__tests__/identity.test.ts +34 -34
- package/src/installer/__tests__/paths.test.ts +47 -47
- package/src/installer/__tests__/service-host.test.ts +62 -62
- package/src/installer/__tests__/wizard.test.ts +64 -64
- package/src/installer/identity.ts +49 -49
- package/src/installer/paths.ts +109 -109
- package/src/installer/platform.ts +27 -27
- package/src/installer/service-host/index.ts +21 -21
- package/src/installer/service-host/template.ts +21 -21
- package/src/installer/wizard.ts +103 -103
- package/src/nat/__tests__/address-resolver.test.ts +82 -82
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
- package/src/nat/__tests__/ddns-updater.test.ts +253 -253
- package/src/nat/__tests__/external-ip.test.ts +124 -124
- package/src/nat/__tests__/nat-service.test.ts +497 -497
- package/src/nat/__tests__/nat-store.test.ts +93 -93
- package/src/nat/__tests__/port-mapper.test.ts +191 -191
- package/src/nat/__tests__/reachability.test.ts +24 -24
- package/src/nat/__tests__/secrets.test.ts +178 -178
- package/src/nat/address-resolver.ts +51 -51
- package/src/nat/ddns/duckdns.ts +76 -76
- package/src/nat/ddns/index.ts +55 -55
- package/src/nat/ddns/updater.ts +243 -243
- package/src/nat/external-ip.ts +154 -154
- package/src/nat/index.ts +71 -71
- package/src/nat/nat-service.ts +501 -501
- package/src/nat/nat-store.ts +154 -154
- package/src/nat/port-mapper.ts +329 -329
- package/src/nat/reachability.ts +34 -34
- package/src/nat/secrets/file-store.ts +130 -130
- package/src/nat/secrets/index.ts +112 -112
- package/src/nat/secrets/keytar-store.ts +67 -67
- package/src/nat/types.ts +159 -159
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
- package/src/orchestrator/index.ts +10 -10
- package/src/orchestrator/log-rotator.ts +53 -53
- package/src/orchestrator/node-identity.ts +40 -40
- package/src/orchestrator/pid-liveness.ts +24 -24
- package/src/orchestrator/port-allocator.ts +91 -91
- package/src/orchestrator/state-store.ts +90 -90
- package/src/orchestrator/types.ts +141 -141
- package/src/owner/__tests__/owner-node-client.test.ts +194 -194
- package/src/owner/index.ts +12 -12
- package/src/owner/owner-node-client.ts +259 -259
- package/src/push/__tests__/push.test.ts +90 -90
- package/src/push/index.ts +146 -146
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +118 -118
- package/src/server/__tests__/grants-admin-route.test.ts +73 -73
- package/src/server/__tests__/grants-route.test.ts +263 -263
- package/src/server/__tests__/nodes-route.test.ts +259 -259
- package/src/server/__tests__/origin-guard.test.ts +72 -72
- package/src/server/__tests__/publishers.test.ts +216 -216
- package/src/server/__tests__/server.smoke.test.ts +253 -253
- package/src/server/__tests__/settings-route.test.ts +194 -194
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/static.test.ts +145 -145
- package/src/server/__tests__/status-route.test.ts +134 -134
- package/src/server/__tests__/strands-route.test.ts +233 -233
- package/src/server/error-handler.ts +178 -178
- package/src/server/events/bus.ts +42 -42
- package/src/server/events/sse-route.ts +81 -81
- package/src/server/events/types.ts +23 -23
- package/src/server/index.ts +267 -267
- package/src/server/origin-guard.ts +84 -84
- package/src/server/routes/grants-admin.ts +41 -41
- package/src/server/routes/grants.ts +210 -210
- package/src/server/routes/nat.ts +63 -63
- package/src/server/routes/nodes.ts +170 -170
- package/src/server/routes/settings.ts +123 -123
- package/src/server/routes/status.ts +118 -118
- package/src/server/routes/strands.ts +54 -54
- package/src/server/routes/trust-circle.ts +49 -49
- package/src/server/routes/update.ts +43 -43
- package/src/server/server.ts +122 -122
- package/src/server/settings-store.ts +37 -37
- package/src/server/static.ts +207 -207
- package/src/strands/__tests__/strand-service.test.ts +165 -165
- package/src/strands/index.ts +14 -14
- package/src/strands/strand-service.ts +135 -135
- package/src/strands/types.ts +102 -102
- package/src/update/__tests__/apply.test.ts +158 -158
- package/src/update/__tests__/manifest.test.ts +128 -128
- package/src/update/__tests__/release-key.test.ts +42 -42
- package/src/update/__tests__/sign.test.ts +78 -78
- package/src/update/__tests__/store.test.ts +81 -81
- package/src/update/__tests__/update-service.integration.test.ts +256 -256
- package/src/update/__tests__/version.test.ts +31 -31
- package/src/update/apply.ts +190 -190
- package/src/update/index.ts +341 -341
- package/src/update/release-key.ts +71 -71
- package/src/update/sign.ts +74 -74
- package/src/update/store.ts +101 -101
- package/src/update/types.ts +109 -109
- package/src/update/version.ts +80 -80
|
@@ -1,10 +1,10 @@
|
|
|
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';
|
|
10
|
-
export type { PersistedHandle } from './state-store.js';
|
|
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';
|
|
10
|
+
export type { PersistedHandle } from './state-store.js';
|
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
renameSync,
|
|
4
|
-
unlinkSync,
|
|
5
|
-
} from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* On-disk log rotation helper.
|
|
10
|
-
*
|
|
11
|
-
* Children spawned by the orchestrator inherit a direct file descriptor to
|
|
12
|
-
* their `node.log` (see `host-process-orchestrator.ts`), so rotation can no
|
|
13
|
-
* longer be driven by an in-process byte counter — renaming the path under a
|
|
14
|
-
* running child would leave it writing to the renamed inode. Instead the
|
|
15
|
-
* orchestrator calls `rotateOnDisk(...)` at spawn time, before the child is
|
|
16
|
-
* launched and its fd is opened. This is the only safe point to rotate
|
|
17
|
-
* without coordinating with the running process.
|
|
18
|
-
*
|
|
19
|
-
* Within a workdir there is at most one live child, so spawn-time rotation
|
|
20
|
-
* is sufficient for v1: a child that exceeds `logMaxBytes` mid-run will
|
|
21
|
-
* keep appending to the active file until it (or its successor) is restarted.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Perform a rotation cascade against on-disk paths: drop the oldest, then
|
|
26
|
-
* shift `.N-1 → .N`, …, `.1 → .2`, and finally `filePath → .1`.
|
|
27
|
-
*
|
|
28
|
-
* Caller is responsible for deciding when to rotate (e.g. when
|
|
29
|
-
* `statSync(filePath).size >= logMaxBytes`). No fd is held by this helper.
|
|
30
|
-
*/
|
|
31
|
-
export function rotateOnDisk(filePath: string, maxFiles: number): void {
|
|
32
|
-
if (maxFiles < 1) return;
|
|
33
|
-
|
|
34
|
-
const oldest = `${filePath}.${maxFiles}`;
|
|
35
|
-
if (existsSync(oldest)) {
|
|
36
|
-
try { unlinkSync(oldest); } catch { /* ignore */ }
|
|
37
|
-
}
|
|
38
|
-
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
39
|
-
const from = `${filePath}.${i}`;
|
|
40
|
-
const to = `${filePath}.${i + 1}`;
|
|
41
|
-
if (existsSync(from)) {
|
|
42
|
-
try { renameSync(from, to); } catch { /* ignore */ }
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
if (existsSync(filePath)) {
|
|
46
|
-
try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Build the standard log file path for a workdir. */
|
|
51
|
-
export function defaultLogPath(workdir: string): string {
|
|
52
|
-
return join(workdir, 'node.log');
|
|
53
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
renameSync,
|
|
4
|
+
unlinkSync,
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* On-disk log rotation helper.
|
|
10
|
+
*
|
|
11
|
+
* Children spawned by the orchestrator inherit a direct file descriptor to
|
|
12
|
+
* their `node.log` (see `host-process-orchestrator.ts`), so rotation can no
|
|
13
|
+
* longer be driven by an in-process byte counter — renaming the path under a
|
|
14
|
+
* running child would leave it writing to the renamed inode. Instead the
|
|
15
|
+
* orchestrator calls `rotateOnDisk(...)` at spawn time, before the child is
|
|
16
|
+
* launched and its fd is opened. This is the only safe point to rotate
|
|
17
|
+
* without coordinating with the running process.
|
|
18
|
+
*
|
|
19
|
+
* Within a workdir there is at most one live child, so spawn-time rotation
|
|
20
|
+
* is sufficient for v1: a child that exceeds `logMaxBytes` mid-run will
|
|
21
|
+
* keep appending to the active file until it (or its successor) is restarted.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Perform a rotation cascade against on-disk paths: drop the oldest, then
|
|
26
|
+
* shift `.N-1 → .N`, …, `.1 → .2`, and finally `filePath → .1`.
|
|
27
|
+
*
|
|
28
|
+
* Caller is responsible for deciding when to rotate (e.g. when
|
|
29
|
+
* `statSync(filePath).size >= logMaxBytes`). No fd is held by this helper.
|
|
30
|
+
*/
|
|
31
|
+
export function rotateOnDisk(filePath: string, maxFiles: number): void {
|
|
32
|
+
if (maxFiles < 1) return;
|
|
33
|
+
|
|
34
|
+
const oldest = `${filePath}.${maxFiles}`;
|
|
35
|
+
if (existsSync(oldest)) {
|
|
36
|
+
try { unlinkSync(oldest); } catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
39
|
+
const from = `${filePath}.${i}`;
|
|
40
|
+
const to = `${filePath}.${i + 1}`;
|
|
41
|
+
if (existsSync(from)) {
|
|
42
|
+
try { renameSync(from, to); } catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (existsSync(filePath)) {
|
|
46
|
+
try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build the standard log file path for a workdir. */
|
|
51
|
+
export function defaultLogPath(workdir: string): string {
|
|
52
|
+
return join(workdir, 'node.log');
|
|
53
|
+
}
|
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-node identity keys for orchestrator-managed children.
|
|
3
|
-
*
|
|
4
|
-
* A donated node joins a *foreign* cadre: the requester approves its peer id
|
|
5
|
-
* and its seed nominates dial addresses keyed to that peer id. Both bind to the
|
|
6
|
-
* identity, so a node that generated a fresh libp2p keypair per process would
|
|
7
|
-
* become a stranger to the cadre that admitted it on every restart. It also
|
|
8
|
-
* needs a stable on-disk home for its node-local stores — `cadre-cli start`
|
|
9
|
-
* opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore` only when a protobuf
|
|
10
|
-
* identity key file is configured.
|
|
11
|
-
*
|
|
12
|
-
* So every managed node gets its own `identity.key` inside its workdir, written
|
|
13
|
-
* once and reused thereafter. The workdir is removed when the loan is
|
|
14
|
-
* terminated (`removeContainer`), so nothing the node persisted outlives it.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { existsSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
|
|
20
|
-
import { generateIdentity, loadIdentity, type IdentityRecord } from '../installer/identity.js';
|
|
21
|
-
|
|
22
|
-
const IDENTITY_FILE = 'identity.key';
|
|
23
|
-
|
|
24
|
-
/** Path to a managed node's own protobuf identity key inside its workdir. */
|
|
25
|
-
export function nodeIdentityPath(workdir: string): string {
|
|
26
|
-
return join(workdir, IDENTITY_FILE);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Ensure `<workdir>/identity.key` exists, generating an Ed25519 protobuf key on
|
|
31
|
-
* first call and REUSING it on every later call. Reuse is the load-bearing
|
|
32
|
-
* property: a re-spawn of the same container must land on the same peer id.
|
|
33
|
-
*/
|
|
34
|
-
export async function ensureNodeIdentity(workdir: string): Promise<{ path: string; peerId: string }> {
|
|
35
|
-
const path = nodeIdentityPath(workdir);
|
|
36
|
-
const record: IdentityRecord = existsSync(path)
|
|
37
|
-
? loadIdentity(path)
|
|
38
|
-
: await generateIdentity(path);
|
|
39
|
-
return { path, peerId: record.peerId };
|
|
40
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Per-node identity keys for orchestrator-managed children.
|
|
3
|
+
*
|
|
4
|
+
* A donated node joins a *foreign* cadre: the requester approves its peer id
|
|
5
|
+
* and its seed nominates dial addresses keyed to that peer id. Both bind to the
|
|
6
|
+
* identity, so a node that generated a fresh libp2p keypair per process would
|
|
7
|
+
* become a stranger to the cadre that admitted it on every restart. It also
|
|
8
|
+
* needs a stable on-disk home for its node-local stores — `cadre-cli start`
|
|
9
|
+
* opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore` only when a protobuf
|
|
10
|
+
* identity key file is configured.
|
|
11
|
+
*
|
|
12
|
+
* So every managed node gets its own `identity.key` inside its workdir, written
|
|
13
|
+
* once and reused thereafter. The workdir is removed when the loan is
|
|
14
|
+
* terminated (`removeContainer`), so nothing the node persisted outlives it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { generateIdentity, loadIdentity, type IdentityRecord } from '../installer/identity.js';
|
|
21
|
+
|
|
22
|
+
const IDENTITY_FILE = 'identity.key';
|
|
23
|
+
|
|
24
|
+
/** Path to a managed node's own protobuf identity key inside its workdir. */
|
|
25
|
+
export function nodeIdentityPath(workdir: string): string {
|
|
26
|
+
return join(workdir, IDENTITY_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ensure `<workdir>/identity.key` exists, generating an Ed25519 protobuf key on
|
|
31
|
+
* first call and REUSING it on every later call. Reuse is the load-bearing
|
|
32
|
+
* property: a re-spawn of the same container must land on the same peer id.
|
|
33
|
+
*/
|
|
34
|
+
export async function ensureNodeIdentity(workdir: string): Promise<{ path: string; peerId: string }> {
|
|
35
|
+
const path = nodeIdentityPath(workdir);
|
|
36
|
+
const record: IdentityRecord = existsSync(path)
|
|
37
|
+
? loadIdentity(path)
|
|
38
|
+
: await generateIdentity(path);
|
|
39
|
+
return { path, peerId: record.peerId };
|
|
40
|
+
}
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-platform PID liveness check using only stdlib.
|
|
3
|
-
*
|
|
4
|
-
* `process.kill(pid, 0)` sends signal 0, which performs error checking
|
|
5
|
-
* without actually delivering a signal:
|
|
6
|
-
* - returns successfully when the process exists and we have permission
|
|
7
|
-
* - throws EPERM when the process exists but is owned by another user
|
|
8
|
-
* (common on Windows where signals don't translate cleanly)
|
|
9
|
-
* - throws ESRCH when the process does not exist
|
|
10
|
-
*/
|
|
11
|
-
export function isPidAlive(pid: number): boolean {
|
|
12
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
13
|
-
return false;
|
|
14
|
-
}
|
|
15
|
-
try {
|
|
16
|
-
process.kill(pid, 0);
|
|
17
|
-
return true;
|
|
18
|
-
} catch (err) {
|
|
19
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
20
|
-
// EPERM means the process exists but we lack permission to signal it.
|
|
21
|
-
// That's still "alive" from a liveness perspective.
|
|
22
|
-
return code === 'EPERM';
|
|
23
|
-
}
|
|
24
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform PID liveness check using only stdlib.
|
|
3
|
+
*
|
|
4
|
+
* `process.kill(pid, 0)` sends signal 0, which performs error checking
|
|
5
|
+
* without actually delivering a signal:
|
|
6
|
+
* - returns successfully when the process exists and we have permission
|
|
7
|
+
* - throws EPERM when the process exists but is owned by another user
|
|
8
|
+
* (common on Windows where signals don't translate cleanly)
|
|
9
|
+
* - throws ESRCH when the process does not exist
|
|
10
|
+
*/
|
|
11
|
+
export function isPidAlive(pid: number): boolean {
|
|
12
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
20
|
+
// EPERM means the process exists but we lack permission to signal it.
|
|
21
|
+
// That's still "alive" from a liveness perspective.
|
|
22
|
+
return code === 'EPERM';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
import type { NodePorts } from './types.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Range-based port tracker. Allocations are in-memory; on orchestrator
|
|
5
|
-
* restart the caller rehydrates the used-set from persisted state via
|
|
6
|
-
* `markUsed(port)` before serving new allocations.
|
|
7
|
-
*/
|
|
8
|
-
export class PortAllocator {
|
|
9
|
-
private readonly usedPorts = new Set<number>();
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
private readonly start: number,
|
|
13
|
-
private readonly end: number,
|
|
14
|
-
) {
|
|
15
|
-
if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
|
|
16
|
-
throw new Error(`Invalid port range: ${start}..${end}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
allocate(): number {
|
|
21
|
-
for (let port = this.start; port <= this.end; port++) {
|
|
22
|
-
if (!this.usedPorts.has(port)) {
|
|
23
|
-
this.usedPorts.add(port);
|
|
24
|
-
return port;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
throw new Error(`No available ports in range ${this.start}..${this.end}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
release(port: number): void {
|
|
31
|
-
this.usedPorts.delete(port);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Used during restart rehydration to reserve ports already in use. */
|
|
35
|
-
markUsed(port: number): void {
|
|
36
|
-
if (port < this.start || port > this.end) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
this.usedPorts.add(port);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
has(port: number): boolean {
|
|
43
|
-
return this.usedPorts.has(port);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Allocation order for a node's port set. Fixed so that swapping a caller onto
|
|
49
|
-
* {@link allocateNodePorts} does not shift the ports an existing deployment
|
|
50
|
-
* already handed out.
|
|
51
|
-
*/
|
|
52
|
-
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin'] as const;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Allocate a full NodePorts set atomically — a mid-way failure (an exhausted
|
|
56
|
-
* range) releases everything already taken rather than stranding the ports it
|
|
57
|
-
* got before the throw. `overrides` supply a port instead of allocating one
|
|
58
|
-
* (the owner node's p2p port, which is fixed by NAT config).
|
|
59
|
-
*
|
|
60
|
-
* Overrides are reserved FIRST, before any allocation, so an override that
|
|
61
|
-
* happens to sit inside the managed range cannot also be handed out to another
|
|
62
|
-
* key. Reserving is `markUsed`, a documented no-op outside the range — which is
|
|
63
|
-
* the production case for the owner node's libp2p port, and why reserving first
|
|
64
|
-
* leaves every real port assignment unchanged.
|
|
65
|
-
*/
|
|
66
|
-
export function allocateNodePorts(
|
|
67
|
-
allocator: PortAllocator,
|
|
68
|
-
overrides?: Partial<NodePorts>,
|
|
69
|
-
): NodePorts {
|
|
70
|
-
const reserved: number[] = [];
|
|
71
|
-
const ports: Partial<NodePorts> = {};
|
|
72
|
-
try {
|
|
73
|
-
for (const key of NODE_PORT_KEYS) {
|
|
74
|
-
const override = overrides?.[key];
|
|
75
|
-
if (override === undefined) continue;
|
|
76
|
-
allocator.markUsed(override);
|
|
77
|
-
reserved.push(override);
|
|
78
|
-
ports[key] = override;
|
|
79
|
-
}
|
|
80
|
-
for (const key of NODE_PORT_KEYS) {
|
|
81
|
-
if (ports[key] !== undefined) continue;
|
|
82
|
-
const port = allocator.allocate();
|
|
83
|
-
reserved.push(port);
|
|
84
|
-
ports[key] = port;
|
|
85
|
-
}
|
|
86
|
-
} catch (err) {
|
|
87
|
-
for (const port of reserved) allocator.release(port);
|
|
88
|
-
throw err;
|
|
89
|
-
}
|
|
90
|
-
return ports as NodePorts;
|
|
91
|
-
}
|
|
1
|
+
import type { NodePorts } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Range-based port tracker. Allocations are in-memory; on orchestrator
|
|
5
|
+
* restart the caller rehydrates the used-set from persisted state via
|
|
6
|
+
* `markUsed(port)` before serving new allocations.
|
|
7
|
+
*/
|
|
8
|
+
export class PortAllocator {
|
|
9
|
+
private readonly usedPorts = new Set<number>();
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly start: number,
|
|
13
|
+
private readonly end: number,
|
|
14
|
+
) {
|
|
15
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
|
|
16
|
+
throw new Error(`Invalid port range: ${start}..${end}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
allocate(): number {
|
|
21
|
+
for (let port = this.start; port <= this.end; port++) {
|
|
22
|
+
if (!this.usedPorts.has(port)) {
|
|
23
|
+
this.usedPorts.add(port);
|
|
24
|
+
return port;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`No available ports in range ${this.start}..${this.end}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
release(port: number): void {
|
|
31
|
+
this.usedPorts.delete(port);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Used during restart rehydration to reserve ports already in use. */
|
|
35
|
+
markUsed(port: number): void {
|
|
36
|
+
if (port < this.start || port > this.end) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.usedPorts.add(port);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
has(port: number): boolean {
|
|
43
|
+
return this.usedPorts.has(port);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Allocation order for a node's port set. Fixed so that swapping a caller onto
|
|
49
|
+
* {@link allocateNodePorts} does not shift the ports an existing deployment
|
|
50
|
+
* already handed out.
|
|
51
|
+
*/
|
|
52
|
+
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin'] as const;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Allocate a full NodePorts set atomically — a mid-way failure (an exhausted
|
|
56
|
+
* range) releases everything already taken rather than stranding the ports it
|
|
57
|
+
* got before the throw. `overrides` supply a port instead of allocating one
|
|
58
|
+
* (the owner node's p2p port, which is fixed by NAT config).
|
|
59
|
+
*
|
|
60
|
+
* Overrides are reserved FIRST, before any allocation, so an override that
|
|
61
|
+
* happens to sit inside the managed range cannot also be handed out to another
|
|
62
|
+
* key. Reserving is `markUsed`, a documented no-op outside the range — which is
|
|
63
|
+
* the production case for the owner node's libp2p port, and why reserving first
|
|
64
|
+
* leaves every real port assignment unchanged.
|
|
65
|
+
*/
|
|
66
|
+
export function allocateNodePorts(
|
|
67
|
+
allocator: PortAllocator,
|
|
68
|
+
overrides?: Partial<NodePorts>,
|
|
69
|
+
): NodePorts {
|
|
70
|
+
const reserved: number[] = [];
|
|
71
|
+
const ports: Partial<NodePorts> = {};
|
|
72
|
+
try {
|
|
73
|
+
for (const key of NODE_PORT_KEYS) {
|
|
74
|
+
const override = overrides?.[key];
|
|
75
|
+
if (override === undefined) continue;
|
|
76
|
+
allocator.markUsed(override);
|
|
77
|
+
reserved.push(override);
|
|
78
|
+
ports[key] = override;
|
|
79
|
+
}
|
|
80
|
+
for (const key of NODE_PORT_KEYS) {
|
|
81
|
+
if (ports[key] !== undefined) continue;
|
|
82
|
+
const port = allocator.allocate();
|
|
83
|
+
reserved.push(port);
|
|
84
|
+
ports[key] = port;
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
for (const port of reserved) allocator.release(port);
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
return ports as NodePorts;
|
|
91
|
+
}
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import debug from 'debug';
|
|
4
|
-
|
|
5
|
-
import type { OwnerSpawnConfig, NodePorts } from './types.js';
|
|
6
|
-
|
|
7
|
-
const log = debug('cadre:host:state-store');
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Persisted record for a single managed child. The orchestrator mirrors its
|
|
11
|
-
* in-memory map to disk so that cadre-host can re-attach to surviving
|
|
12
|
-
* children after a restart.
|
|
13
|
-
*/
|
|
14
|
-
export interface PersistedHandle {
|
|
15
|
-
containerId: string; // provider-assigned ID, passed in createContainer
|
|
16
|
-
dockerId: string; // opaque token returned to callers (encoded "pid:token")
|
|
17
|
-
pid: number;
|
|
18
|
-
startupToken: string;
|
|
19
|
-
workdir: string;
|
|
20
|
-
ports: NodePorts;
|
|
21
|
-
spawnedAt: string; // ISO timestamp
|
|
22
|
-
partyId: string;
|
|
23
|
-
profile: 'storage' | 'transaction';
|
|
24
|
-
/** True for the admin's owner node. */
|
|
25
|
-
owner?: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface PersistedState {
|
|
29
|
-
version: 1;
|
|
30
|
-
handles: PersistedHandle[];
|
|
31
|
-
/** Spawn parameters for the owner node, so it can be re-spawned on demand. */
|
|
32
|
-
ownerConfig?: OwnerSpawnConfig;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const STATE_VERSION = 1;
|
|
36
|
-
|
|
37
|
-
/** JSON state file at `<rootDir>/state.json`, written atomically. */
|
|
38
|
-
export class StateStore {
|
|
39
|
-
private readonly path: string;
|
|
40
|
-
|
|
41
|
-
constructor(rootDir: string) {
|
|
42
|
-
mkdirSync(rootDir, { recursive: true });
|
|
43
|
-
this.path = join(rootDir, 'state.json');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
load(): PersistedState {
|
|
47
|
-
if (!existsSync(this.path)) {
|
|
48
|
-
return { version: STATE_VERSION, handles: [] };
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
const raw = readFileSync(this.path, 'utf8');
|
|
52
|
-
const parsed = JSON.parse(raw) as PersistedState;
|
|
53
|
-
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.handles)) {
|
|
54
|
-
log('state file at %s has unexpected shape; treating as empty', this.path);
|
|
55
|
-
return { version: STATE_VERSION, handles: [] };
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
version: STATE_VERSION,
|
|
59
|
-
handles: parsed.handles,
|
|
60
|
-
...(parsed.ownerConfig ? { ownerConfig: parsed.ownerConfig } : {}),
|
|
61
|
-
};
|
|
62
|
-
} catch (err) {
|
|
63
|
-
// A corrupt state file is operationally significant — surviving children
|
|
64
|
-
// may now be leaked to the OS without an in-memory handle. Make the
|
|
65
|
-
// failure visible rather than silently returning empty.
|
|
66
|
-
log('failed to load state file at %s: %o', this.path, err);
|
|
67
|
-
return { version: STATE_VERSION, handles: [] };
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
save(state: PersistedState): void {
|
|
72
|
-
mkdirSync(dirname(this.path), { recursive: true });
|
|
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
|
-
);
|
|
82
|
-
const tmp = `${this.path}.tmp`;
|
|
83
|
-
writeFileSync(tmp, payload, { encoding: 'utf8' });
|
|
84
|
-
renameSync(tmp, this.path);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
filePath(): string {
|
|
88
|
-
return this.path;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
|
|
5
|
+
import type { OwnerSpawnConfig, NodePorts } from './types.js';
|
|
6
|
+
|
|
7
|
+
const log = debug('cadre:host:state-store');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Persisted record for a single managed child. The orchestrator mirrors its
|
|
11
|
+
* in-memory map to disk so that cadre-host can re-attach to surviving
|
|
12
|
+
* children after a restart.
|
|
13
|
+
*/
|
|
14
|
+
export interface PersistedHandle {
|
|
15
|
+
containerId: string; // provider-assigned ID, passed in createContainer
|
|
16
|
+
dockerId: string; // opaque token returned to callers (encoded "pid:token")
|
|
17
|
+
pid: number;
|
|
18
|
+
startupToken: string;
|
|
19
|
+
workdir: string;
|
|
20
|
+
ports: NodePorts;
|
|
21
|
+
spawnedAt: string; // ISO timestamp
|
|
22
|
+
partyId: string;
|
|
23
|
+
profile: 'storage' | 'transaction';
|
|
24
|
+
/** True for the admin's owner node. */
|
|
25
|
+
owner?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PersistedState {
|
|
29
|
+
version: 1;
|
|
30
|
+
handles: PersistedHandle[];
|
|
31
|
+
/** Spawn parameters for the owner node, so it can be re-spawned on demand. */
|
|
32
|
+
ownerConfig?: OwnerSpawnConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const STATE_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
/** JSON state file at `<rootDir>/state.json`, written atomically. */
|
|
38
|
+
export class StateStore {
|
|
39
|
+
private readonly path: string;
|
|
40
|
+
|
|
41
|
+
constructor(rootDir: string) {
|
|
42
|
+
mkdirSync(rootDir, { recursive: true });
|
|
43
|
+
this.path = join(rootDir, 'state.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
load(): PersistedState {
|
|
47
|
+
if (!existsSync(this.path)) {
|
|
48
|
+
return { version: STATE_VERSION, handles: [] };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(this.path, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw) as PersistedState;
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.handles)) {
|
|
54
|
+
log('state file at %s has unexpected shape; treating as empty', this.path);
|
|
55
|
+
return { version: STATE_VERSION, handles: [] };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
version: STATE_VERSION,
|
|
59
|
+
handles: parsed.handles,
|
|
60
|
+
...(parsed.ownerConfig ? { ownerConfig: parsed.ownerConfig } : {}),
|
|
61
|
+
};
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// A corrupt state file is operationally significant — surviving children
|
|
64
|
+
// may now be leaked to the OS without an in-memory handle. Make the
|
|
65
|
+
// failure visible rather than silently returning empty.
|
|
66
|
+
log('failed to load state file at %s: %o', this.path, err);
|
|
67
|
+
return { version: STATE_VERSION, handles: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
save(state: PersistedState): void {
|
|
72
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
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
|
+
);
|
|
82
|
+
const tmp = `${this.path}.tmp`;
|
|
83
|
+
writeFileSync(tmp, payload, { encoding: 'utf8' });
|
|
84
|
+
renameSync(tmp, this.path);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
filePath(): string {
|
|
88
|
+
return this.path;
|
|
89
|
+
}
|
|
90
|
+
}
|