@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.
Files changed (138) hide show
  1. package/dist/server/static.js +38 -38
  2. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  3. package/dist/ui/index.html +12 -12
  4. package/package.json +4 -4
  5. package/service/cadre-host.service.tmpl +16 -16
  6. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  7. package/service/install-service.ps1 +98 -98
  8. package/service/uninstall-service.ps1 +19 -19
  9. package/src/__tests__/build-targets.test.ts +26 -26
  10. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  11. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  12. package/src/__tests__/cli.smoke.test.ts +46 -46
  13. package/src/__tests__/global-setup.ts +51 -51
  14. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  15. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  16. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  17. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  18. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  19. package/src/__tests__/orchestrator-push.test.ts +145 -145
  20. package/src/__tests__/orchestrator.test.ts +951 -951
  21. package/src/auth/__tests__/duration.test.ts +28 -28
  22. package/src/auth/__tests__/self-label.test.ts +85 -85
  23. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  24. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  25. package/src/auth/duration.ts +33 -33
  26. package/src/auth/index.ts +21 -21
  27. package/src/auth/self-label.ts +74 -74
  28. package/src/auth/trust-circle-store.ts +155 -155
  29. package/src/auth/types.ts +99 -99
  30. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  31. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  32. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  33. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  34. package/src/donation/__tests__/grant-service.test.ts +213 -213
  35. package/src/donation/__tests__/grant-store.test.ts +113 -113
  36. package/src/donation/donation-service.ts +962 -962
  37. package/src/donation/donation-store.ts +171 -171
  38. package/src/donation/donation-supervisor.ts +388 -388
  39. package/src/donation/grant-service.ts +200 -200
  40. package/src/donation/grant-store.ts +152 -152
  41. package/src/donation/index.ts +64 -64
  42. package/src/donation/types.ts +243 -243
  43. package/src/installer/__tests__/identity.test.ts +34 -34
  44. package/src/installer/__tests__/paths.test.ts +47 -47
  45. package/src/installer/__tests__/service-host.test.ts +62 -62
  46. package/src/installer/__tests__/wizard.test.ts +64 -64
  47. package/src/installer/identity.ts +49 -49
  48. package/src/installer/paths.ts +109 -109
  49. package/src/installer/platform.ts +27 -27
  50. package/src/installer/service-host/index.ts +21 -21
  51. package/src/installer/service-host/template.ts +21 -21
  52. package/src/installer/wizard.ts +103 -103
  53. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  54. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  55. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  56. package/src/nat/__tests__/external-ip.test.ts +124 -124
  57. package/src/nat/__tests__/nat-service.test.ts +497 -497
  58. package/src/nat/__tests__/nat-store.test.ts +93 -93
  59. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  60. package/src/nat/__tests__/reachability.test.ts +24 -24
  61. package/src/nat/__tests__/secrets.test.ts +178 -178
  62. package/src/nat/address-resolver.ts +51 -51
  63. package/src/nat/ddns/duckdns.ts +76 -76
  64. package/src/nat/ddns/index.ts +55 -55
  65. package/src/nat/ddns/updater.ts +243 -243
  66. package/src/nat/external-ip.ts +154 -154
  67. package/src/nat/index.ts +71 -71
  68. package/src/nat/nat-service.ts +501 -501
  69. package/src/nat/nat-store.ts +154 -154
  70. package/src/nat/port-mapper.ts +329 -329
  71. package/src/nat/reachability.ts +34 -34
  72. package/src/nat/secrets/file-store.ts +130 -130
  73. package/src/nat/secrets/index.ts +112 -112
  74. package/src/nat/secrets/keytar-store.ts +67 -67
  75. package/src/nat/types.ts +159 -159
  76. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  77. package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
  78. package/src/orchestrator/index.ts +10 -10
  79. package/src/orchestrator/log-rotator.ts +53 -53
  80. package/src/orchestrator/node-identity.ts +40 -40
  81. package/src/orchestrator/pid-liveness.ts +24 -24
  82. package/src/orchestrator/port-allocator.ts +91 -91
  83. package/src/orchestrator/state-store.ts +90 -90
  84. package/src/orchestrator/types.ts +141 -141
  85. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  86. package/src/owner/index.ts +12 -12
  87. package/src/owner/owner-node-client.ts +259 -259
  88. package/src/push/__tests__/push.test.ts +90 -90
  89. package/src/push/index.ts +146 -146
  90. package/src/server/__tests__/bus.test.ts +38 -38
  91. package/src/server/__tests__/error-handler.test.ts +118 -118
  92. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  93. package/src/server/__tests__/grants-route.test.ts +263 -263
  94. package/src/server/__tests__/nodes-route.test.ts +259 -259
  95. package/src/server/__tests__/origin-guard.test.ts +72 -72
  96. package/src/server/__tests__/publishers.test.ts +216 -216
  97. package/src/server/__tests__/server.smoke.test.ts +253 -253
  98. package/src/server/__tests__/settings-route.test.ts +194 -194
  99. package/src/server/__tests__/sse-route.test.ts +119 -119
  100. package/src/server/__tests__/static.test.ts +145 -145
  101. package/src/server/__tests__/status-route.test.ts +134 -134
  102. package/src/server/__tests__/strands-route.test.ts +233 -233
  103. package/src/server/error-handler.ts +178 -178
  104. package/src/server/events/bus.ts +42 -42
  105. package/src/server/events/sse-route.ts +81 -81
  106. package/src/server/events/types.ts +23 -23
  107. package/src/server/index.ts +267 -267
  108. package/src/server/origin-guard.ts +84 -84
  109. package/src/server/routes/grants-admin.ts +41 -41
  110. package/src/server/routes/grants.ts +210 -210
  111. package/src/server/routes/nat.ts +63 -63
  112. package/src/server/routes/nodes.ts +170 -170
  113. package/src/server/routes/settings.ts +123 -123
  114. package/src/server/routes/status.ts +118 -118
  115. package/src/server/routes/strands.ts +54 -54
  116. package/src/server/routes/trust-circle.ts +49 -49
  117. package/src/server/routes/update.ts +43 -43
  118. package/src/server/server.ts +122 -122
  119. package/src/server/settings-store.ts +37 -37
  120. package/src/server/static.ts +207 -207
  121. package/src/strands/__tests__/strand-service.test.ts +165 -165
  122. package/src/strands/index.ts +14 -14
  123. package/src/strands/strand-service.ts +135 -135
  124. package/src/strands/types.ts +102 -102
  125. package/src/update/__tests__/apply.test.ts +158 -158
  126. package/src/update/__tests__/manifest.test.ts +128 -128
  127. package/src/update/__tests__/release-key.test.ts +42 -42
  128. package/src/update/__tests__/sign.test.ts +78 -78
  129. package/src/update/__tests__/store.test.ts +81 -81
  130. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  131. package/src/update/__tests__/version.test.ts +31 -31
  132. package/src/update/apply.ts +190 -190
  133. package/src/update/index.ts +341 -341
  134. package/src/update/release-key.ts +71 -71
  135. package/src/update/sign.ts +74 -74
  136. package/src/update/store.ts +101 -101
  137. package/src/update/types.ts +109 -109
  138. 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
+ }