@serfab/cadre-host 0.7.1

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 (300) hide show
  1. package/README.md +101 -0
  2. package/dist/auth/duration.d.ts +1 -0
  3. package/dist/auth/duration.js +32 -0
  4. package/dist/auth/duration.js.map +1 -0
  5. package/dist/auth/index.d.ts +9 -0
  6. package/dist/auth/index.js +8 -0
  7. package/dist/auth/index.js.map +1 -0
  8. package/dist/auth/trust-circle-store.d.ts +26 -0
  9. package/dist/auth/trust-circle-store.js +129 -0
  10. package/dist/auth/trust-circle-store.js.map +1 -0
  11. package/dist/auth/trust-circle.d.ts +91 -0
  12. package/dist/auth/trust-circle.js +230 -0
  13. package/dist/auth/trust-circle.js.map +1 -0
  14. package/dist/auth/types.d.ts +81 -0
  15. package/dist/auth/types.js +24 -0
  16. package/dist/auth/types.js.map +1 -0
  17. package/dist/bin/host.d.ts +13 -0
  18. package/dist/bin/host.js +826 -0
  19. package/dist/bin/host.js.map +1 -0
  20. package/dist/index.d.ts +32 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/installer/browser.d.ts +14 -0
  24. package/dist/installer/browser.js +35 -0
  25. package/dist/installer/browser.js.map +1 -0
  26. package/dist/installer/config.d.ts +43 -0
  27. package/dist/installer/config.js +113 -0
  28. package/dist/installer/config.js.map +1 -0
  29. package/dist/installer/identity.d.ts +23 -0
  30. package/dist/installer/identity.js +36 -0
  31. package/dist/installer/identity.js.map +1 -0
  32. package/dist/installer/index.d.ts +83 -0
  33. package/dist/installer/index.js +204 -0
  34. package/dist/installer/index.js.map +1 -0
  35. package/dist/installer/paths.d.ts +33 -0
  36. package/dist/installer/paths.js +84 -0
  37. package/dist/installer/paths.js.map +1 -0
  38. package/dist/installer/platform.d.ts +9 -0
  39. package/dist/installer/platform.js +21 -0
  40. package/dist/installer/platform.js.map +1 -0
  41. package/dist/installer/service-host/index.d.ts +11 -0
  42. package/dist/installer/service-host/index.js +16 -0
  43. package/dist/installer/service-host/index.js.map +1 -0
  44. package/dist/installer/service-host/launchd.d.ts +16 -0
  45. package/dist/installer/service-host/launchd.js +79 -0
  46. package/dist/installer/service-host/launchd.js.map +1 -0
  47. package/dist/installer/service-host/nssm.d.ts +20 -0
  48. package/dist/installer/service-host/nssm.js +88 -0
  49. package/dist/installer/service-host/nssm.js.map +1 -0
  50. package/dist/installer/service-host/systemd.d.ts +16 -0
  51. package/dist/installer/service-host/systemd.js +78 -0
  52. package/dist/installer/service-host/systemd.js.map +1 -0
  53. package/dist/installer/service-host/template.d.ts +6 -0
  54. package/dist/installer/service-host/template.js +20 -0
  55. package/dist/installer/service-host/template.js.map +1 -0
  56. package/dist/installer/service-host/types.d.ts +40 -0
  57. package/dist/installer/service-host/types.js +6 -0
  58. package/dist/installer/service-host/types.js.map +1 -0
  59. package/dist/installer/wizard.d.ts +31 -0
  60. package/dist/installer/wizard.js +67 -0
  61. package/dist/installer/wizard.js.map +1 -0
  62. package/dist/nat/address-resolver.d.ts +22 -0
  63. package/dist/nat/address-resolver.js +31 -0
  64. package/dist/nat/address-resolver.js.map +1 -0
  65. package/dist/nat/ddns/duckdns.d.ts +12 -0
  66. package/dist/nat/ddns/duckdns.js +57 -0
  67. package/dist/nat/ddns/duckdns.js.map +1 -0
  68. package/dist/nat/ddns/index.d.ts +30 -0
  69. package/dist/nat/ddns/index.js +21 -0
  70. package/dist/nat/ddns/index.js.map +1 -0
  71. package/dist/nat/ddns/updater.d.ts +68 -0
  72. package/dist/nat/ddns/updater.js +194 -0
  73. package/dist/nat/ddns/updater.js.map +1 -0
  74. package/dist/nat/external-ip.d.ts +62 -0
  75. package/dist/nat/external-ip.js +114 -0
  76. package/dist/nat/external-ip.js.map +1 -0
  77. package/dist/nat/index.d.ts +22 -0
  78. package/dist/nat/index.js +16 -0
  79. package/dist/nat/index.js.map +1 -0
  80. package/dist/nat/nat-service.d.ts +101 -0
  81. package/dist/nat/nat-service.js +284 -0
  82. package/dist/nat/nat-service.js.map +1 -0
  83. package/dist/nat/nat-store.d.ts +19 -0
  84. package/dist/nat/nat-store.js +136 -0
  85. package/dist/nat/nat-store.js.map +1 -0
  86. package/dist/nat/port-mapper.d.ts +98 -0
  87. package/dist/nat/port-mapper.js +226 -0
  88. package/dist/nat/port-mapper.js.map +1 -0
  89. package/dist/nat/reachability.d.ts +19 -0
  90. package/dist/nat/reachability.js +29 -0
  91. package/dist/nat/reachability.js.map +1 -0
  92. package/dist/nat/secrets/file-store.d.ts +23 -0
  93. package/dist/nat/secrets/file-store.js +102 -0
  94. package/dist/nat/secrets/file-store.js.map +1 -0
  95. package/dist/nat/secrets/index.d.ts +36 -0
  96. package/dist/nat/secrets/index.js +56 -0
  97. package/dist/nat/secrets/index.js.map +1 -0
  98. package/dist/nat/secrets/keytar-store.d.ts +18 -0
  99. package/dist/nat/secrets/keytar-store.js +52 -0
  100. package/dist/nat/secrets/keytar-store.js.map +1 -0
  101. package/dist/nat/types.d.ts +120 -0
  102. package/dist/nat/types.js +24 -0
  103. package/dist/nat/types.js.map +1 -0
  104. package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
  105. package/dist/orchestrator/host-process-orchestrator.js +557 -0
  106. package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
  107. package/dist/orchestrator/index.d.ts +3 -0
  108. package/dist/orchestrator/index.js +2 -0
  109. package/dist/orchestrator/index.js.map +1 -0
  110. package/dist/orchestrator/log-rotator.d.ts +25 -0
  111. package/dist/orchestrator/log-rotator.js +56 -0
  112. package/dist/orchestrator/log-rotator.js.map +1 -0
  113. package/dist/orchestrator/pid-liveness.d.ts +11 -0
  114. package/dist/orchestrator/pid-liveness.js +26 -0
  115. package/dist/orchestrator/pid-liveness.js.map +1 -0
  116. package/dist/orchestrator/port-allocator.d.ts +16 -0
  117. package/dist/orchestrator/port-allocator.js +38 -0
  118. package/dist/orchestrator/port-allocator.js.map +1 -0
  119. package/dist/orchestrator/state-store.d.ts +32 -0
  120. package/dist/orchestrator/state-store.js +44 -0
  121. package/dist/orchestrator/state-store.js.map +1 -0
  122. package/dist/orchestrator/types.d.ts +85 -0
  123. package/dist/orchestrator/types.js +31 -0
  124. package/dist/orchestrator/types.js.map +1 -0
  125. package/dist/server/error-handler.d.ts +26 -0
  126. package/dist/server/error-handler.js +95 -0
  127. package/dist/server/error-handler.js.map +1 -0
  128. package/dist/server/events/bus.d.ts +17 -0
  129. package/dist/server/events/bus.js +36 -0
  130. package/dist/server/events/bus.js.map +1 -0
  131. package/dist/server/events/sse-route.d.ts +22 -0
  132. package/dist/server/events/sse-route.js +65 -0
  133. package/dist/server/events/sse-route.js.map +1 -0
  134. package/dist/server/events/types.d.ts +25 -0
  135. package/dist/server/events/types.js +8 -0
  136. package/dist/server/events/types.js.map +1 -0
  137. package/dist/server/index.d.ts +67 -0
  138. package/dist/server/index.js +149 -0
  139. package/dist/server/index.js.map +1 -0
  140. package/dist/server/origin-guard.d.ts +24 -0
  141. package/dist/server/origin-guard.js +72 -0
  142. package/dist/server/origin-guard.js.map +1 -0
  143. package/dist/server/routes/nat.d.ts +16 -0
  144. package/dist/server/routes/nat.js +42 -0
  145. package/dist/server/routes/nat.js.map +1 -0
  146. package/dist/server/routes/nodes.d.ts +23 -0
  147. package/dist/server/routes/nodes.js +128 -0
  148. package/dist/server/routes/nodes.js.map +1 -0
  149. package/dist/server/routes/settings.d.ts +26 -0
  150. package/dist/server/routes/settings.js +97 -0
  151. package/dist/server/routes/settings.js.map +1 -0
  152. package/dist/server/routes/status.d.ts +42 -0
  153. package/dist/server/routes/status.js +76 -0
  154. package/dist/server/routes/status.js.map +1 -0
  155. package/dist/server/routes/trust-circle.d.ts +17 -0
  156. package/dist/server/routes/trust-circle.js +35 -0
  157. package/dist/server/routes/trust-circle.js.map +1 -0
  158. package/dist/server/routes/update.d.ts +16 -0
  159. package/dist/server/routes/update.js +29 -0
  160. package/dist/server/routes/update.js.map +1 -0
  161. package/dist/server/server.d.ts +40 -0
  162. package/dist/server/server.js +90 -0
  163. package/dist/server/server.js.map +1 -0
  164. package/dist/server/settings-store.d.ts +24 -0
  165. package/dist/server/settings-store.js +27 -0
  166. package/dist/server/settings-store.js.map +1 -0
  167. package/dist/server/static.d.ts +25 -0
  168. package/dist/server/static.js +177 -0
  169. package/dist/server/static.js.map +1 -0
  170. package/dist/ui/assets/index-BwU5mJGF.css +1 -0
  171. package/dist/ui/assets/index-C9uChk2P.js +14 -0
  172. package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
  173. package/dist/ui/index.html +14 -0
  174. package/dist/update/apply.d.ts +37 -0
  175. package/dist/update/apply.js +102 -0
  176. package/dist/update/apply.js.map +1 -0
  177. package/dist/update/index.d.ts +101 -0
  178. package/dist/update/index.js +228 -0
  179. package/dist/update/index.js.map +1 -0
  180. package/dist/update/manifest.d.ts +48 -0
  181. package/dist/update/manifest.js +152 -0
  182. package/dist/update/manifest.js.map +1 -0
  183. package/dist/update/release-key.d.ts +24 -0
  184. package/dist/update/release-key.js +52 -0
  185. package/dist/update/release-key.js.map +1 -0
  186. package/dist/update/store.d.ts +22 -0
  187. package/dist/update/store.js +81 -0
  188. package/dist/update/store.js.map +1 -0
  189. package/dist/update/types.d.ts +90 -0
  190. package/dist/update/types.js +22 -0
  191. package/dist/update/types.js.map +1 -0
  192. package/dist/update/version.d.ts +18 -0
  193. package/dist/update/version.js +76 -0
  194. package/dist/update/version.js.map +1 -0
  195. package/package.json +80 -0
  196. package/service/README.md +86 -0
  197. package/service/cadre-host.service.tmpl +16 -0
  198. package/service/com.serfab.cadre-host.plist.tmpl +35 -0
  199. package/service/install-service.ps1 +98 -0
  200. package/service/uninstall-service.ps1 +19 -0
  201. package/src/__tests__/cli-invite.smoke.test.ts +187 -0
  202. package/src/__tests__/cli-nat.smoke.test.ts +207 -0
  203. package/src/__tests__/cli.smoke.test.ts +41 -0
  204. package/src/__tests__/orchestrator.test.ts +570 -0
  205. package/src/auth/__tests__/duration.test.ts +28 -0
  206. package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
  207. package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
  208. package/src/auth/__tests__/trust-circle.test.ts +334 -0
  209. package/src/auth/duration.ts +33 -0
  210. package/src/auth/index.ts +19 -0
  211. package/src/auth/trust-circle-store.ts +155 -0
  212. package/src/auth/trust-circle.ts +304 -0
  213. package/src/auth/types.ts +96 -0
  214. package/src/bin/host.ts +905 -0
  215. package/src/index.ts +167 -0
  216. package/src/installer/__tests__/config.test.ts +86 -0
  217. package/src/installer/__tests__/identity.test.ts +34 -0
  218. package/src/installer/__tests__/installer.smoke.test.ts +124 -0
  219. package/src/installer/__tests__/paths.test.ts +47 -0
  220. package/src/installer/__tests__/service-host.test.ts +62 -0
  221. package/src/installer/__tests__/wizard.test.ts +60 -0
  222. package/src/installer/browser.ts +42 -0
  223. package/src/installer/config.ts +153 -0
  224. package/src/installer/identity.ts +49 -0
  225. package/src/installer/index.ts +303 -0
  226. package/src/installer/paths.ts +109 -0
  227. package/src/installer/platform.ts +27 -0
  228. package/src/installer/service-host/index.ts +21 -0
  229. package/src/installer/service-host/launchd.ts +90 -0
  230. package/src/installer/service-host/nssm.ts +100 -0
  231. package/src/installer/service-host/systemd.ts +88 -0
  232. package/src/installer/service-host/template.ts +21 -0
  233. package/src/installer/service-host/types.ts +48 -0
  234. package/src/installer/wizard.ts +92 -0
  235. package/src/nat/__tests__/address-resolver.test.ts +82 -0
  236. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
  237. package/src/nat/__tests__/ddns-updater.test.ts +253 -0
  238. package/src/nat/__tests__/external-ip.test.ts +124 -0
  239. package/src/nat/__tests__/nat-service.test.ts +305 -0
  240. package/src/nat/__tests__/nat-store.test.ts +93 -0
  241. package/src/nat/__tests__/port-mapper.test.ts +191 -0
  242. package/src/nat/__tests__/reachability.test.ts +24 -0
  243. package/src/nat/__tests__/secrets.test.ts +156 -0
  244. package/src/nat/address-resolver.ts +51 -0
  245. package/src/nat/ddns/duckdns.ts +76 -0
  246. package/src/nat/ddns/index.ts +55 -0
  247. package/src/nat/ddns/updater.ts +243 -0
  248. package/src/nat/external-ip.ts +154 -0
  249. package/src/nat/index.ts +71 -0
  250. package/src/nat/nat-service.ts +375 -0
  251. package/src/nat/nat-store.ts +154 -0
  252. package/src/nat/port-mapper.ts +329 -0
  253. package/src/nat/reachability.ts +34 -0
  254. package/src/nat/secrets/file-store.ts +130 -0
  255. package/src/nat/secrets/index.ts +82 -0
  256. package/src/nat/secrets/keytar-store.ts +67 -0
  257. package/src/nat/types.ts +157 -0
  258. package/src/orchestrator/host-process-orchestrator.ts +626 -0
  259. package/src/orchestrator/index.ts +3 -0
  260. package/src/orchestrator/log-rotator.ts +53 -0
  261. package/src/orchestrator/pid-liveness.ts +24 -0
  262. package/src/orchestrator/port-allocator.ts +43 -0
  263. package/src/orchestrator/state-store.ts +72 -0
  264. package/src/orchestrator/types.ts +95 -0
  265. package/src/server/__tests__/bus.test.ts +38 -0
  266. package/src/server/__tests__/error-handler.test.ts +96 -0
  267. package/src/server/__tests__/nodes-route.test.ts +179 -0
  268. package/src/server/__tests__/origin-guard.test.ts +72 -0
  269. package/src/server/__tests__/publishers.test.ts +216 -0
  270. package/src/server/__tests__/server.smoke.test.ts +178 -0
  271. package/src/server/__tests__/settings-route.test.ts +177 -0
  272. package/src/server/__tests__/sse-route.test.ts +119 -0
  273. package/src/server/__tests__/status-route.test.ts +113 -0
  274. package/src/server/error-handler.ts +108 -0
  275. package/src/server/events/bus.ts +42 -0
  276. package/src/server/events/sse-route.ts +81 -0
  277. package/src/server/events/types.ts +22 -0
  278. package/src/server/index.ts +209 -0
  279. package/src/server/origin-guard.ts +84 -0
  280. package/src/server/routes/nat.ts +63 -0
  281. package/src/server/routes/nodes.ts +147 -0
  282. package/src/server/routes/settings.ts +115 -0
  283. package/src/server/routes/status.ts +108 -0
  284. package/src/server/routes/trust-circle.ts +49 -0
  285. package/src/server/routes/update.ts +43 -0
  286. package/src/server/server.ts +122 -0
  287. package/src/server/settings-store.ts +37 -0
  288. package/src/server/static.ts +191 -0
  289. package/src/update/__tests__/apply.test.ts +101 -0
  290. package/src/update/__tests__/manifest.test.ts +93 -0
  291. package/src/update/__tests__/store.test.ts +81 -0
  292. package/src/update/__tests__/update-service.integration.test.ts +221 -0
  293. package/src/update/__tests__/version.test.ts +31 -0
  294. package/src/update/apply.ts +148 -0
  295. package/src/update/index.ts +303 -0
  296. package/src/update/manifest.ts +172 -0
  297. package/src/update/release-key.ts +55 -0
  298. package/src/update/store.ts +101 -0
  299. package/src/update/types.ts +109 -0
  300. package/src/update/version.ts +80 -0
@@ -0,0 +1,626 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { randomBytes } from 'node:crypto';
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ mkdirSync,
10
+ statSync,
11
+ openSync,
12
+ readSync,
13
+ closeSync,
14
+ } from 'node:fs';
15
+ import { join, resolve as resolvePath } from 'node:path';
16
+ import debug from 'debug';
17
+ import pidusage from 'pidusage';
18
+ import type {
19
+ Orchestrator,
20
+ OrchestratorCreateRequest,
21
+ OrchestratorCreateResult,
22
+ OrchestratorStats,
23
+ } from '@serfab/cadre-provider';
24
+
25
+ import { defaultLogPath, rotateOnDisk } from './log-rotator.js';
26
+ import { PortAllocator } from './port-allocator.js';
27
+ import { StateStore, type PersistedHandle, type PersistedState } from './state-store.js';
28
+ import { isPidAlive } from './pid-liveness.js';
29
+ import {
30
+ decodeDockerId,
31
+ encodeDockerId,
32
+ type Handle,
33
+ type HostProcessConfig,
34
+ type ManagedNodeInfo,
35
+ type NodeStateListener,
36
+ } from './types.js';
37
+
38
+ const log = debug('cadre:host:orchestrator');
39
+
40
+ const DEFAULTS = {
41
+ portRangeStart: 10000,
42
+ portRangeEnd: 20000,
43
+ stopTimeoutMs: 10000,
44
+ logMaxBytes: 10 * 1024 * 1024,
45
+ logMaxFiles: 5,
46
+ };
47
+
48
+ const STARTUP_TOKEN_FILE = '.startup-token';
49
+ const CONFIG_FILE = 'cadre.json';
50
+ const LIVENESS_POLL_MS = 100;
51
+
52
+ /**
53
+ * Runs cadre nodes as plain Node child processes on the host.
54
+ *
55
+ * Each container gets its own workdir under `rootDir`. Handles are mirrored
56
+ * to `<rootDir>/state.json` so cadre-host can re-attach after a restart.
57
+ *
58
+ * ## Child stdio: direct-fd inheritance
59
+ *
60
+ * Spawned children inherit a file descriptor opened on `<workdir>/node.log`
61
+ * directly as their stdout and stderr. The orchestrator closes its own copy
62
+ * of the fd immediately after spawn, so the child's write path no longer
63
+ * touches any handle owned by this process. This is what lets children
64
+ * survive — and keep logging through — an orchestrator restart. Were the
65
+ * orchestrator instead piping stdout/stderr through an in-process Writable,
66
+ * a parent exit would deliver SIGPIPE to the child (POSIX) or fail its
67
+ * writes with EPIPE (Windows), silently breaking the documented "init()
68
+ * re-attach" flow.
69
+ *
70
+ * ## Log rotation: spawn-time only
71
+ *
72
+ * Because the child holds a stable fd open against the inode, the
73
+ * orchestrator cannot safely rename `node.log` while the child is running.
74
+ * Rotation therefore happens only at spawn time, when no live child is
75
+ * attached to the workdir (`createContainer` is the single producer per
76
+ * workdir). If `node.log` already exceeds `logMaxBytes`, the rotation
77
+ * cascade runs against the on-disk paths before the fresh fd is opened.
78
+ * A child whose log grows past `logMaxBytes` mid-run will keep appending
79
+ * to the active file until it (or its successor) is restarted; v1 traffic
80
+ * is low enough that this is acceptable.
81
+ */
82
+ export class HostProcessOrchestrator implements Orchestrator {
83
+ private readonly rootDir: string;
84
+ private readonly portAllocator: PortAllocator;
85
+ private readonly stateStore: StateStore;
86
+ private readonly stopTimeoutMs: number;
87
+ private readonly logMaxBytes: number;
88
+ private readonly logMaxFiles: number;
89
+ private readonly defaultMemoryLimit?: string;
90
+ private readonly handles = new Map<string, Handle>();
91
+ private readonly cliEntrypoint: string;
92
+ /** State-change listeners — invoked when a handle's alive state changes. */
93
+ private readonly stateListeners = new Set<NodeStateListener>();
94
+
95
+ constructor(private readonly cfg: HostProcessConfig) {
96
+ this.rootDir = resolvePath(cfg.rootDir);
97
+ mkdirSync(this.rootDir, { recursive: true });
98
+ this.portAllocator = new PortAllocator(
99
+ cfg.portRange?.start ?? DEFAULTS.portRangeStart,
100
+ cfg.portRange?.end ?? DEFAULTS.portRangeEnd,
101
+ );
102
+ this.stateStore = new StateStore(this.rootDir);
103
+ this.stopTimeoutMs = cfg.stopTimeoutMs ?? DEFAULTS.stopTimeoutMs;
104
+ this.logMaxBytes = cfg.logMaxBytes ?? DEFAULTS.logMaxBytes;
105
+ this.logMaxFiles = cfg.logMaxFiles ?? DEFAULTS.logMaxFiles;
106
+ this.defaultMemoryLimit = cfg.defaultResources?.memoryLimit;
107
+ this.cliEntrypoint = cfg.spawn?.entrypoint ?? resolveCadreCliBin();
108
+ log('HostProcessOrchestrator rootDir=%s cli=%s', this.rootDir, this.cliEntrypoint);
109
+ }
110
+
111
+ /**
112
+ * Read persisted state and re-attach to surviving children. Not part of
113
+ * `Orchestrator` — call once before handing the instance to a container
114
+ * service.
115
+ */
116
+ async init(): Promise<void> {
117
+ const state = this.stateStore.load();
118
+ for (const persisted of state.handles) {
119
+ const alive = isPidAlive(persisted.pid) && tokenMatches(persisted.workdir, persisted.startupToken);
120
+ const handle: Handle = {
121
+ containerId: persisted.containerId,
122
+ dockerId: persisted.dockerId,
123
+ pid: persisted.pid,
124
+ startupToken: persisted.startupToken,
125
+ workdir: persisted.workdir,
126
+ ports: persisted.ports,
127
+ spawnedAt: persisted.spawnedAt,
128
+ partyId: persisted.partyId,
129
+ profile: persisted.profile,
130
+ alive,
131
+ };
132
+ this.handles.set(handle.dockerId, handle);
133
+ // Always reserve ports — even for dead handles, until the caller cleans them up
134
+ this.portAllocator.markUsed(persisted.ports.health);
135
+ this.portAllocator.markUsed(persisted.ports.metrics);
136
+ this.portAllocator.markUsed(persisted.ports.p2p);
137
+ }
138
+ log('init: re-attached %d handles', state.handles.length);
139
+ }
140
+
141
+ /**
142
+ * Snapshot of currently-known managed nodes. Used by the local UI's
143
+ * `/api/nodes` route.
144
+ */
145
+ listNodes(): ManagedNodeInfo[] {
146
+ return [...this.handles.values()].map((h) => toNodeInfo(h));
147
+ }
148
+
149
+ /**
150
+ * Look up a single managed node by either containerId (friendly id) or
151
+ * dockerId (opaque `pid:token`). Returns undefined when neither matches.
152
+ */
153
+ getNode(idOrDockerId: string): ManagedNodeInfo | undefined {
154
+ const direct = this.handles.get(idOrDockerId);
155
+ if (direct) return toNodeInfo(direct);
156
+ for (const h of this.handles.values()) {
157
+ if (h.containerId === idOrDockerId) return toNodeInfo(h);
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ /**
163
+ * Resolve the opaque dockerId for a friendly containerId so callers can
164
+ * pass it into stopContainer / getLogs / getStats.
165
+ */
166
+ resolveDockerId(idOrDockerId: string): string | undefined {
167
+ if (this.handles.has(idOrDockerId)) return idOrDockerId;
168
+ for (const h of this.handles.values()) {
169
+ if (h.containerId === idOrDockerId) return h.dockerId;
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ /**
175
+ * Register a state-change listener. Invoked when a handle's `alive`
176
+ * transitions (child exit, manual stop). Returns an unsubscribe fn.
177
+ */
178
+ onStateChange(listener: NodeStateListener): () => void {
179
+ this.stateListeners.add(listener);
180
+ return () => { this.stateListeners.delete(listener); };
181
+ }
182
+
183
+ /** Handles that init() found dead (PID gone or token mismatch). */
184
+ listDeadHandles(): PersistedHandle[] {
185
+ const dead: PersistedHandle[] = [];
186
+ for (const h of this.handles.values()) {
187
+ if (!h.alive) {
188
+ dead.push(this.toPersisted(h));
189
+ }
190
+ }
191
+ return dead;
192
+ }
193
+
194
+ async createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult> {
195
+ const healthPort = this.portAllocator.allocate();
196
+ const metricsPort = this.portAllocator.allocate();
197
+ const p2pPort = this.portAllocator.allocate();
198
+
199
+ const workdir = join(this.rootDir, request.containerId);
200
+ mkdirSync(workdir, { recursive: true });
201
+ mkdirSync(join(workdir, 'storage'), { recursive: true });
202
+
203
+ const token = randomBytes(16).toString('hex');
204
+ const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
205
+ // Ensure stale token from a previous container with the same id is cleared.
206
+ if (existsSync(tokenPath)) {
207
+ try { rmSync(tokenPath); } catch { /* ignore */ }
208
+ }
209
+
210
+ const configPath = join(workdir, CONFIG_FILE);
211
+ writeFileSync(
212
+ configPath,
213
+ JSON.stringify(this.buildChildConfig(request, workdir), null, 2),
214
+ 'utf8',
215
+ );
216
+
217
+ const memoryLimit = request.resources?.memoryLimit ?? this.defaultMemoryLimit;
218
+ const heapBytes = parseMemoryLimit(memoryLimit);
219
+ const heapMB = heapBytes !== undefined ? Math.max(64, Math.floor(heapBytes / (1024 * 1024))) : undefined;
220
+
221
+ const env: NodeJS.ProcessEnv = {
222
+ ...process.env,
223
+ CADRE_STARTUP_TOKEN: token,
224
+ CADRE_HEALTH_PORT: String(healthPort),
225
+ CADRE_METRICS_PORT: String(metricsPort),
226
+ CADRE_LISTEN_ADDRS: `/ip4/0.0.0.0/tcp/${p2pPort}`,
227
+ };
228
+ if (heapMB !== undefined) {
229
+ env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=${heapMB}`.trim();
230
+ }
231
+
232
+ // Rotate the on-disk log files BEFORE opening the fd — once the child
233
+ // is running it holds the inode open and any later rename would leave
234
+ // it writing to the rotated file. See class docstring.
235
+ const logPath = defaultLogPath(workdir);
236
+ maybeRotateAtSpawn(logPath, this.logMaxBytes, this.logMaxFiles);
237
+ const logFd = openSync(logPath, 'a');
238
+
239
+ let child: ChildProcess;
240
+ try {
241
+ child = spawn(
242
+ process.execPath,
243
+ [
244
+ this.cliEntrypoint,
245
+ 'start',
246
+ '-c', configPath,
247
+ '--health-port', String(healthPort),
248
+ '--metrics-port', String(metricsPort),
249
+ '--startup-token-file', tokenPath,
250
+ ],
251
+ {
252
+ cwd: workdir,
253
+ detached: true,
254
+ // Inherit the log fd directly. OS duplicates it into the child;
255
+ // closing our copy below is safe and severs the parent's only
256
+ // tie to the child's write path.
257
+ stdio: ['ignore', logFd, logFd],
258
+ env,
259
+ },
260
+ );
261
+ } catch (err) {
262
+ // Synchronous spawn failure — release the fd and the reserved ports
263
+ // before propagating, so a bad-args bug doesn't leak resources.
264
+ closeSync(logFd);
265
+ this.portAllocator.release(healthPort);
266
+ this.portAllocator.release(metricsPort);
267
+ this.portAllocator.release(p2pPort);
268
+ throw err;
269
+ }
270
+ // Always release the parent's copy of the fd — the child has its own.
271
+ closeSync(logFd);
272
+
273
+ if (!child.pid) {
274
+ // Spawn returned without a pid — release reservations and report.
275
+ this.portAllocator.release(healthPort);
276
+ this.portAllocator.release(metricsPort);
277
+ this.portAllocator.release(p2pPort);
278
+ throw new Error(`Failed to spawn child for container ${request.containerId}`);
279
+ }
280
+
281
+ child.unref();
282
+
283
+ const dockerId = encodeDockerId(child.pid, token);
284
+ const handle: Handle = {
285
+ containerId: request.containerId,
286
+ dockerId,
287
+ pid: child.pid,
288
+ startupToken: token,
289
+ workdir,
290
+ ports: { health: healthPort, metrics: metricsPort, p2p: p2pPort },
291
+ spawnedAt: new Date().toISOString(),
292
+ partyId: request.partyId,
293
+ profile: request.profile,
294
+ child,
295
+ alive: true,
296
+ };
297
+
298
+ child.once('exit', () => {
299
+ const h = this.handles.get(dockerId);
300
+ if (h && h.alive) {
301
+ h.alive = false;
302
+ this.emitStateChange(h);
303
+ }
304
+ });
305
+
306
+ this.handles.set(dockerId, handle);
307
+ this.persist();
308
+ this.emitStateChange(handle);
309
+
310
+ log('created container %s -> pid %d ports=%j', request.containerId, child.pid, handle.ports);
311
+
312
+ return {
313
+ dockerId,
314
+ healthEndpoint: `http://localhost:${healthPort}/health`,
315
+ metricsEndpoint: `http://localhost:${metricsPort}/metrics`,
316
+ p2pPort,
317
+ };
318
+ }
319
+
320
+ async stopContainer(dockerId: string): Promise<void> {
321
+ const handle = this.requireHandle(dockerId);
322
+ if (!isPidAlive(handle.pid)) {
323
+ handle.alive = false;
324
+ this.persist();
325
+ return;
326
+ }
327
+
328
+ // Capture the exit event before we send signals so we can await full
329
+ // teardown — important on Windows where cwd handles release lazily.
330
+ const exitPromise = handle.child
331
+ ? new Promise<void>((resolve) => handle.child!.once('exit', () => resolve()))
332
+ : Promise.resolve();
333
+
334
+ log('SIGTERM container %s (pid=%d)', handle.containerId, handle.pid);
335
+ try {
336
+ process.kill(handle.pid, 'SIGTERM');
337
+ } catch (err) {
338
+ const code = (err as NodeJS.ErrnoException).code;
339
+ if (code !== 'ESRCH') {
340
+ throw err;
341
+ }
342
+ }
343
+
344
+ const deadline = Date.now() + this.stopTimeoutMs;
345
+ while (Date.now() < deadline) {
346
+ if (!isPidAlive(handle.pid)) break;
347
+ await sleep(LIVENESS_POLL_MS);
348
+ }
349
+
350
+ if (isPidAlive(handle.pid)) {
351
+ log('SIGKILL container %s (pid=%d)', handle.containerId, handle.pid);
352
+ try {
353
+ process.kill(handle.pid, 'SIGKILL');
354
+ } catch (err) {
355
+ const code = (err as NodeJS.ErrnoException).code;
356
+ if (code !== 'ESRCH') {
357
+ throw err;
358
+ }
359
+ }
360
+ const killDeadline = Date.now() + 2000;
361
+ while (Date.now() < killDeadline && isPidAlive(handle.pid)) {
362
+ await sleep(LIVENESS_POLL_MS);
363
+ }
364
+ }
365
+
366
+ // Wait briefly for the OS to fully tear down the child (Windows cwd lock).
367
+ await Promise.race([exitPromise, sleep(1000)]);
368
+
369
+ const wasAlive = handle.alive;
370
+ handle.alive = false;
371
+ this.persist();
372
+ if (wasAlive) this.emitStateChange(handle);
373
+ }
374
+
375
+ async removeContainer(dockerId: string): Promise<void> {
376
+ const handle = this.requireHandle(dockerId);
377
+ if (isPidAlive(handle.pid)) {
378
+ await this.stopContainer(dockerId);
379
+ }
380
+
381
+ this.portAllocator.release(handle.ports.health);
382
+ this.portAllocator.release(handle.ports.metrics);
383
+ this.portAllocator.release(handle.ports.p2p);
384
+
385
+ // On Windows the OS releases the child's cwd handle asynchronously, so
386
+ // retry rmSync to give the kernel a chance to drop it.
387
+ try {
388
+ rmSync(handle.workdir, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
389
+ } catch (err) {
390
+ log('failed to remove workdir %s: %o', handle.workdir, err);
391
+ }
392
+
393
+ this.handles.delete(dockerId);
394
+ this.persist();
395
+ }
396
+
397
+ async isRunning(dockerId: string): Promise<boolean> {
398
+ let handle: Handle | undefined = this.handles.get(dockerId);
399
+ let pid: number;
400
+ let token: string;
401
+ let workdir: string | undefined;
402
+
403
+ if (handle) {
404
+ pid = handle.pid;
405
+ token = handle.startupToken;
406
+ workdir = handle.workdir;
407
+ } else {
408
+ try {
409
+ const decoded = decodeDockerId(dockerId);
410
+ pid = decoded.pid;
411
+ token = decoded.token;
412
+ } catch {
413
+ return false;
414
+ }
415
+ }
416
+
417
+ if (!isPidAlive(pid)) {
418
+ if (handle) handle.alive = false;
419
+ return false;
420
+ }
421
+ if (workdir === undefined) {
422
+ // We have no record — without a workdir we can't verify the token,
423
+ // so we must treat this as unknown rather than running.
424
+ return false;
425
+ }
426
+ return tokenMatches(workdir, token);
427
+ }
428
+
429
+ async getStats(dockerId: string): Promise<OrchestratorStats> {
430
+ const handle = this.requireHandle(dockerId);
431
+ if (!isPidAlive(handle.pid)) {
432
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
433
+ }
434
+ try {
435
+ const usage = await pidusage(handle.pid);
436
+ return {
437
+ cpuPercent: usage.cpu,
438
+ memoryBytes: usage.memory,
439
+ networkRxBytes: 0,
440
+ networkTxBytes: 0,
441
+ };
442
+ } catch (err) {
443
+ log('pidusage failed for %d: %o', handle.pid, err);
444
+ return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
445
+ }
446
+ }
447
+
448
+ async getLogs(dockerId: string, tail = 100): Promise<string> {
449
+ const handle = this.requireHandle(dockerId);
450
+ const logPath = defaultLogPath(handle.workdir);
451
+ return tailFile(logPath, tail);
452
+ }
453
+
454
+ private requireHandle(dockerId: string): Handle {
455
+ const handle = this.handles.get(dockerId);
456
+ if (!handle) {
457
+ throw new Error(`Container not found: ${dockerId}`);
458
+ }
459
+ return handle;
460
+ }
461
+
462
+ private buildChildConfig(req: OrchestratorCreateRequest, workdir: string): Record<string, unknown> {
463
+ const cfg: Record<string, unknown> = {
464
+ controlNetwork: {
465
+ partyId: req.partyId,
466
+ bootstrapNodes: req.bootstrapNodes,
467
+ },
468
+ profile: req.profile,
469
+ strandFilter: req.strandFilter ?? 'all',
470
+ };
471
+ if (req.profile === 'storage') {
472
+ const storage: Record<string, unknown> = {
473
+ type: 'file',
474
+ path: join(workdir, 'storage'),
475
+ };
476
+ if (req.resources?.storageQuotaBytes !== undefined) {
477
+ storage.quotaBytes = req.resources.storageQuotaBytes;
478
+ }
479
+ cfg.storage = storage;
480
+ }
481
+ return cfg;
482
+ }
483
+
484
+ private persist(): void {
485
+ const state: PersistedState = {
486
+ version: 1,
487
+ handles: [...this.handles.values()].map((h) => this.toPersisted(h)),
488
+ };
489
+ this.stateStore.save(state);
490
+ }
491
+
492
+ private emitStateChange(handle: Handle): void {
493
+ if (this.stateListeners.size === 0) return;
494
+ const info = toNodeInfo(handle);
495
+ for (const listener of this.stateListeners) {
496
+ try {
497
+ listener(info);
498
+ } catch (err) {
499
+ log('state listener threw for %s: %o', info.id, err);
500
+ }
501
+ }
502
+ }
503
+
504
+ private toPersisted(h: Handle): PersistedHandle {
505
+ return {
506
+ containerId: h.containerId,
507
+ dockerId: h.dockerId,
508
+ pid: h.pid,
509
+ startupToken: h.startupToken,
510
+ workdir: h.workdir,
511
+ ports: h.ports,
512
+ spawnedAt: h.spawnedAt,
513
+ partyId: h.partyId,
514
+ profile: h.profile,
515
+ };
516
+ }
517
+ }
518
+
519
+ function toNodeInfo(h: Handle): ManagedNodeInfo {
520
+ return {
521
+ id: h.containerId,
522
+ dockerId: h.dockerId,
523
+ partyId: h.partyId,
524
+ profile: h.profile,
525
+ status: h.alive ? 'running' : 'stopped',
526
+ spawnedAt: h.spawnedAt,
527
+ workdir: h.workdir,
528
+ ports: { ...h.ports },
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Locate the cadre-cli bin via the consumer-style module resolver. Works for
534
+ * both yarn-managed workspaces and traditional npm installs.
535
+ */
536
+ function resolveCadreCliBin(): string {
537
+ const req = createRequire(import.meta.url);
538
+ try {
539
+ return req.resolve('@serfab/cadre-cli/bin/cadre.js');
540
+ } catch (err) {
541
+ throw new Error(
542
+ `Unable to resolve @serfab/cadre-cli bin from cadre-host. ` +
543
+ `Ensure cadre-cli is built (yarn workspace @serfab/cadre-cli build) ` +
544
+ `and listed as a dependency. Underlying error: ${(err as Error).message}`,
545
+ );
546
+ }
547
+ }
548
+
549
+ function tokenMatches(workdir: string, expected: string): boolean {
550
+ const tokenPath = join(workdir, STARTUP_TOKEN_FILE);
551
+ try {
552
+ const contents = readFileSync(tokenPath, 'utf8').trim();
553
+ return contents === expected;
554
+ } catch {
555
+ return false;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Rotate the on-disk log files for a workdir if the active file is already
561
+ * at or above the byte threshold. Called from `createContainer` before the
562
+ * child's stdio fd is opened — the only safe rotation point because the
563
+ * caller guarantees no live child is attached to this workdir.
564
+ */
565
+ function maybeRotateAtSpawn(logPath: string, maxBytes: number, maxFiles: number): void {
566
+ if (maxBytes <= 0 || maxFiles < 1) return;
567
+ let size = 0;
568
+ try {
569
+ size = statSync(logPath).size;
570
+ } catch {
571
+ return;
572
+ }
573
+ if (size >= maxBytes) {
574
+ rotateOnDisk(logPath, maxFiles);
575
+ }
576
+ }
577
+
578
+ function sleep(ms: number): Promise<void> {
579
+ return new Promise((res) => {
580
+ const t = setTimeout(res, ms);
581
+ if (typeof t.unref === 'function') t.unref();
582
+ });
583
+ }
584
+
585
+ /**
586
+ * Read the last `n` lines from a log file. Reads from the tail in chunks
587
+ * rather than slurping the whole file.
588
+ */
589
+ function tailFile(path: string, n: number): string {
590
+ if (!existsSync(path)) return '';
591
+ const fd = openSync(path, 'r');
592
+ try {
593
+ const size = statSync(path).size;
594
+ const chunkSize = 8192;
595
+ let pos = size;
596
+ const buffers: Buffer[] = [];
597
+ let newlineCount = 0;
598
+ while (pos > 0 && newlineCount <= n) {
599
+ const readLen = Math.min(chunkSize, pos);
600
+ pos -= readLen;
601
+ const buf = Buffer.alloc(readLen);
602
+ readSync(fd, buf, 0, readLen, pos);
603
+ buffers.unshift(buf);
604
+ for (let i = 0; i < buf.length; i++) {
605
+ if (buf[i] === 0x0a) newlineCount++;
606
+ }
607
+ }
608
+ const joined = Buffer.concat(buffers).toString('utf8');
609
+ const lines = joined.split('\n');
610
+ // Drop any trailing empty line from a terminating newline before counting.
611
+ const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
612
+ return trimmed.slice(-n).join('\n');
613
+ } finally {
614
+ closeSync(fd);
615
+ }
616
+ }
617
+
618
+ function parseMemoryLimit(limit?: string): number | undefined {
619
+ if (!limit) return undefined;
620
+ const match = limit.match(/^(\d+(?:\.\d+)?)\s*(B|K|M|G|T)?$/i);
621
+ if (!match) return undefined;
622
+ const [, num, unit] = match;
623
+ const multipliers: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
624
+ return Math.floor(parseFloat(num!) * (multipliers[unit?.toUpperCase() ?? 'B'] ?? 1));
625
+ }
626
+
@@ -0,0 +1,3 @@
1
+ export { HostProcessOrchestrator } from './host-process-orchestrator.js';
2
+ export type { HostProcessConfig, ManagedNodeInfo, NodeStateListener } from './types.js';
3
+ export type { PersistedHandle } from './state-store.js';
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }