@serfab/cadre-host 0.9.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 (213) hide show
  1. package/dist/auth/index.d.ts +2 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/auth/index.js.map +1 -1
  4. package/dist/auth/self-label.d.ts +36 -0
  5. package/dist/auth/self-label.js +57 -0
  6. package/dist/auth/self-label.js.map +1 -0
  7. package/dist/auth/trust-circle.d.ts +32 -5
  8. package/dist/auth/trust-circle.js +31 -11
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/bin/host.js +41 -5
  11. package/dist/bin/host.js.map +1 -1
  12. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  13. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  15. package/dist/donation/donation-service.d.ts +237 -12
  16. package/dist/donation/donation-service.js +491 -34
  17. package/dist/donation/donation-service.js.map +1 -1
  18. package/dist/donation/donation-store.d.ts +7 -0
  19. package/dist/donation/donation-store.js +7 -0
  20. package/dist/donation/donation-store.js.map +1 -1
  21. package/dist/donation/donation-supervisor.d.ts +167 -0
  22. package/dist/donation/donation-supervisor.js +341 -0
  23. package/dist/donation/donation-supervisor.js.map +1 -0
  24. package/dist/donation/index.d.ts +4 -2
  25. package/dist/donation/index.js +2 -1
  26. package/dist/donation/index.js.map +1 -1
  27. package/dist/donation/types.d.ts +26 -0
  28. package/dist/donation/types.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/orchestrator/host-process-orchestrator.d.ts +109 -5
  33. package/dist/orchestrator/host-process-orchestrator.js +341 -98
  34. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  35. package/dist/orchestrator/node-identity.d.ts +26 -0
  36. package/dist/orchestrator/node-identity.js +36 -0
  37. package/dist/orchestrator/node-identity.js.map +1 -0
  38. package/dist/orchestrator/port-allocator.d.ts +14 -0
  39. package/dist/orchestrator/port-allocator.js +45 -0
  40. package/dist/orchestrator/port-allocator.js.map +1 -1
  41. package/dist/owner/owner-node-client.d.ts +20 -5
  42. package/dist/owner/owner-node-client.js +21 -4
  43. package/dist/owner/owner-node-client.js.map +1 -1
  44. package/dist/server/error-handler.d.ts +7 -1
  45. package/dist/server/error-handler.js +23 -1
  46. package/dist/server/error-handler.js.map +1 -1
  47. package/dist/server/events/types.d.ts +3 -0
  48. package/dist/server/index.d.ts +7 -0
  49. package/dist/server/index.js +9 -3
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/routes/grants.js +22 -3
  52. package/dist/server/routes/grants.js.map +1 -1
  53. package/dist/server/routes/strands.d.ts +20 -0
  54. package/dist/server/routes/strands.js +38 -0
  55. package/dist/server/routes/strands.js.map +1 -0
  56. package/dist/server/static.js +38 -38
  57. package/dist/strands/index.d.ts +7 -0
  58. package/dist/strands/index.js +6 -0
  59. package/dist/strands/index.js.map +1 -0
  60. package/dist/strands/strand-service.d.ts +50 -0
  61. package/dist/strands/strand-service.js +103 -0
  62. package/dist/strands/strand-service.js.map +1 -0
  63. package/dist/strands/types.d.ts +93 -0
  64. package/dist/strands/types.js +23 -0
  65. package/dist/strands/types.js.map +1 -0
  66. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  67. package/dist/ui/assets/index-C-iks41M.js +17 -0
  68. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  69. package/dist/ui/index.html +14 -14
  70. package/dist/update/release-key.d.ts +1 -1
  71. package/dist/update/release-key.js +1 -1
  72. package/package.json +5 -5
  73. package/service/cadre-host.service.tmpl +16 -16
  74. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  75. package/service/install-service.ps1 +98 -98
  76. package/service/uninstall-service.ps1 +19 -19
  77. package/src/__tests__/build-targets.test.ts +26 -0
  78. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  79. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  80. package/src/__tests__/cli.smoke.test.ts +46 -46
  81. package/src/__tests__/global-setup.ts +51 -0
  82. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  83. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  84. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  85. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  86. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  87. package/src/__tests__/orchestrator-push.test.ts +145 -145
  88. package/src/__tests__/orchestrator.test.ts +951 -570
  89. package/src/auth/__tests__/duration.test.ts +28 -28
  90. package/src/auth/__tests__/self-label.test.ts +85 -0
  91. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  92. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  93. package/src/auth/__tests__/trust-circle.test.ts +445 -401
  94. package/src/auth/duration.ts +33 -33
  95. package/src/auth/index.ts +21 -19
  96. package/src/auth/self-label.ts +74 -0
  97. package/src/auth/trust-circle-store.ts +155 -155
  98. package/src/auth/trust-circle.ts +45 -13
  99. package/src/auth/types.ts +99 -99
  100. package/src/bin/host.ts +1299 -1258
  101. package/src/donation/__tests__/donation-service.test.ts +1068 -290
  102. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  103. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  104. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  105. package/src/donation/__tests__/grant-service.test.ts +213 -213
  106. package/src/donation/__tests__/grant-store.test.ts +113 -113
  107. package/src/donation/donation-service.ts +962 -392
  108. package/src/donation/donation-store.ts +171 -164
  109. package/src/donation/donation-supervisor.ts +388 -0
  110. package/src/donation/grant-service.ts +200 -200
  111. package/src/donation/grant-store.ts +152 -152
  112. package/src/donation/index.ts +64 -49
  113. package/src/donation/types.ts +243 -220
  114. package/src/index.ts +14 -0
  115. package/src/installer/__tests__/identity.test.ts +34 -34
  116. package/src/installer/__tests__/paths.test.ts +47 -47
  117. package/src/installer/__tests__/service-host.test.ts +62 -62
  118. package/src/installer/__tests__/wizard.test.ts +64 -64
  119. package/src/installer/identity.ts +49 -49
  120. package/src/installer/paths.ts +109 -109
  121. package/src/installer/platform.ts +27 -27
  122. package/src/installer/service-host/index.ts +21 -21
  123. package/src/installer/service-host/template.ts +21 -21
  124. package/src/installer/wizard.ts +103 -103
  125. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  126. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  127. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  128. package/src/nat/__tests__/external-ip.test.ts +124 -124
  129. package/src/nat/__tests__/nat-service.test.ts +497 -497
  130. package/src/nat/__tests__/nat-store.test.ts +93 -93
  131. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  132. package/src/nat/__tests__/reachability.test.ts +24 -24
  133. package/src/nat/__tests__/secrets.test.ts +178 -178
  134. package/src/nat/address-resolver.ts +51 -51
  135. package/src/nat/ddns/duckdns.ts +76 -76
  136. package/src/nat/ddns/index.ts +55 -55
  137. package/src/nat/ddns/updater.ts +243 -243
  138. package/src/nat/external-ip.ts +154 -154
  139. package/src/nat/index.ts +71 -71
  140. package/src/nat/nat-service.ts +501 -501
  141. package/src/nat/nat-store.ts +154 -154
  142. package/src/nat/port-mapper.ts +329 -329
  143. package/src/nat/reachability.ts +34 -34
  144. package/src/nat/secrets/file-store.ts +130 -130
  145. package/src/nat/secrets/index.ts +112 -112
  146. package/src/nat/secrets/keytar-store.ts +67 -67
  147. package/src/nat/types.ts +159 -159
  148. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  149. package/src/orchestrator/host-process-orchestrator.ts +1133 -894
  150. package/src/orchestrator/index.ts +10 -10
  151. package/src/orchestrator/log-rotator.ts +53 -53
  152. package/src/orchestrator/node-identity.ts +40 -0
  153. package/src/orchestrator/pid-liveness.ts +24 -24
  154. package/src/orchestrator/port-allocator.ts +91 -43
  155. package/src/orchestrator/state-store.ts +90 -90
  156. package/src/orchestrator/types.ts +141 -141
  157. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  158. package/src/owner/index.ts +12 -12
  159. package/src/owner/owner-node-client.ts +259 -233
  160. package/src/push/__tests__/push.test.ts +90 -90
  161. package/src/push/index.ts +146 -146
  162. package/src/server/__tests__/bus.test.ts +38 -38
  163. package/src/server/__tests__/error-handler.test.ts +118 -118
  164. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  165. package/src/server/__tests__/grants-route.test.ts +263 -189
  166. package/src/server/__tests__/nodes-route.test.ts +259 -259
  167. package/src/server/__tests__/origin-guard.test.ts +72 -72
  168. package/src/server/__tests__/publishers.test.ts +216 -216
  169. package/src/server/__tests__/server.smoke.test.ts +253 -253
  170. package/src/server/__tests__/settings-route.test.ts +194 -194
  171. package/src/server/__tests__/sse-route.test.ts +119 -119
  172. package/src/server/__tests__/static.test.ts +145 -145
  173. package/src/server/__tests__/status-route.test.ts +134 -134
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +178 -155
  176. package/src/server/events/bus.ts +42 -42
  177. package/src/server/events/sse-route.ts +81 -81
  178. package/src/server/events/types.ts +23 -22
  179. package/src/server/index.ts +267 -254
  180. package/src/server/origin-guard.ts +84 -84
  181. package/src/server/routes/grants-admin.ts +41 -41
  182. package/src/server/routes/grants.ts +210 -186
  183. package/src/server/routes/nat.ts +63 -63
  184. package/src/server/routes/nodes.ts +170 -170
  185. package/src/server/routes/settings.ts +123 -123
  186. package/src/server/routes/status.ts +118 -118
  187. package/src/server/routes/strands.ts +54 -0
  188. package/src/server/routes/trust-circle.ts +49 -49
  189. package/src/server/routes/update.ts +43 -43
  190. package/src/server/server.ts +122 -122
  191. package/src/server/settings-store.ts +37 -37
  192. package/src/server/static.ts +207 -207
  193. package/src/strands/__tests__/strand-service.test.ts +165 -0
  194. package/src/strands/index.ts +14 -0
  195. package/src/strands/strand-service.ts +135 -0
  196. package/src/strands/types.ts +102 -0
  197. package/src/update/__tests__/apply.test.ts +158 -158
  198. package/src/update/__tests__/manifest.test.ts +128 -128
  199. package/src/update/__tests__/release-key.test.ts +42 -42
  200. package/src/update/__tests__/sign.test.ts +78 -78
  201. package/src/update/__tests__/store.test.ts +81 -81
  202. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  203. package/src/update/__tests__/version.test.ts +31 -31
  204. package/src/update/apply.ts +190 -190
  205. package/src/update/index.ts +341 -341
  206. package/src/update/release-key.ts +71 -71
  207. package/src/update/sign.ts +74 -74
  208. package/src/update/store.ts +101 -101
  209. package/src/update/types.ts +109 -109
  210. package/src/update/version.ts +80 -80
  211. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  212. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  213. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
@@ -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
+ }
@@ -0,0 +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,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,43 +1,91 @@
1
- /**
2
- * Range-based port tracker. Allocations are in-memory; on orchestrator
3
- * restart the caller rehydrates the used-set from persisted state via
4
- * `markUsed(port)` before serving new allocations.
5
- */
6
- export class PortAllocator {
7
- private readonly usedPorts = new Set<number>();
8
-
9
- constructor(
10
- private readonly start: number,
11
- private readonly end: number,
12
- ) {
13
- if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
14
- throw new Error(`Invalid port range: ${start}..${end}`);
15
- }
16
- }
17
-
18
- allocate(): number {
19
- for (let port = this.start; port <= this.end; port++) {
20
- if (!this.usedPorts.has(port)) {
21
- this.usedPorts.add(port);
22
- return port;
23
- }
24
- }
25
- throw new Error(`No available ports in range ${this.start}..${this.end}`);
26
- }
27
-
28
- release(port: number): void {
29
- this.usedPorts.delete(port);
30
- }
31
-
32
- /** Used during restart rehydration to reserve ports already in use. */
33
- markUsed(port: number): void {
34
- if (port < this.start || port > this.end) {
35
- return;
36
- }
37
- this.usedPorts.add(port);
38
- }
39
-
40
- has(port: number): boolean {
41
- return this.usedPorts.has(port);
42
- }
43
- }
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
+ }