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