@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.
- package/README.md +101 -0
- package/dist/auth/duration.d.ts +1 -0
- package/dist/auth/duration.js +32 -0
- package/dist/auth/duration.js.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/trust-circle-store.d.ts +26 -0
- package/dist/auth/trust-circle-store.js +129 -0
- package/dist/auth/trust-circle-store.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +91 -0
- package/dist/auth/trust-circle.js +230 -0
- package/dist/auth/trust-circle.js.map +1 -0
- package/dist/auth/types.d.ts +81 -0
- package/dist/auth/types.js +24 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/bin/host.d.ts +13 -0
- package/dist/bin/host.js +826 -0
- package/dist/bin/host.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/browser.d.ts +14 -0
- package/dist/installer/browser.js +35 -0
- package/dist/installer/browser.js.map +1 -0
- package/dist/installer/config.d.ts +43 -0
- package/dist/installer/config.js +113 -0
- package/dist/installer/config.js.map +1 -0
- package/dist/installer/identity.d.ts +23 -0
- package/dist/installer/identity.js +36 -0
- package/dist/installer/identity.js.map +1 -0
- package/dist/installer/index.d.ts +83 -0
- package/dist/installer/index.js +204 -0
- package/dist/installer/index.js.map +1 -0
- package/dist/installer/paths.d.ts +33 -0
- package/dist/installer/paths.js +84 -0
- package/dist/installer/paths.js.map +1 -0
- package/dist/installer/platform.d.ts +9 -0
- package/dist/installer/platform.js +21 -0
- package/dist/installer/platform.js.map +1 -0
- package/dist/installer/service-host/index.d.ts +11 -0
- package/dist/installer/service-host/index.js +16 -0
- package/dist/installer/service-host/index.js.map +1 -0
- package/dist/installer/service-host/launchd.d.ts +16 -0
- package/dist/installer/service-host/launchd.js +79 -0
- package/dist/installer/service-host/launchd.js.map +1 -0
- package/dist/installer/service-host/nssm.d.ts +20 -0
- package/dist/installer/service-host/nssm.js +88 -0
- package/dist/installer/service-host/nssm.js.map +1 -0
- package/dist/installer/service-host/systemd.d.ts +16 -0
- package/dist/installer/service-host/systemd.js +78 -0
- package/dist/installer/service-host/systemd.js.map +1 -0
- package/dist/installer/service-host/template.d.ts +6 -0
- package/dist/installer/service-host/template.js +20 -0
- package/dist/installer/service-host/template.js.map +1 -0
- package/dist/installer/service-host/types.d.ts +40 -0
- package/dist/installer/service-host/types.js +6 -0
- package/dist/installer/service-host/types.js.map +1 -0
- package/dist/installer/wizard.d.ts +31 -0
- package/dist/installer/wizard.js +67 -0
- package/dist/installer/wizard.js.map +1 -0
- package/dist/nat/address-resolver.d.ts +22 -0
- package/dist/nat/address-resolver.js +31 -0
- package/dist/nat/address-resolver.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +12 -0
- package/dist/nat/ddns/duckdns.js +57 -0
- package/dist/nat/ddns/duckdns.js.map +1 -0
- package/dist/nat/ddns/index.d.ts +30 -0
- package/dist/nat/ddns/index.js +21 -0
- package/dist/nat/ddns/index.js.map +1 -0
- package/dist/nat/ddns/updater.d.ts +68 -0
- package/dist/nat/ddns/updater.js +194 -0
- package/dist/nat/ddns/updater.js.map +1 -0
- package/dist/nat/external-ip.d.ts +62 -0
- package/dist/nat/external-ip.js +114 -0
- package/dist/nat/external-ip.js.map +1 -0
- package/dist/nat/index.d.ts +22 -0
- package/dist/nat/index.js +16 -0
- package/dist/nat/index.js.map +1 -0
- package/dist/nat/nat-service.d.ts +101 -0
- package/dist/nat/nat-service.js +284 -0
- package/dist/nat/nat-service.js.map +1 -0
- package/dist/nat/nat-store.d.ts +19 -0
- package/dist/nat/nat-store.js +136 -0
- package/dist/nat/nat-store.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +98 -0
- package/dist/nat/port-mapper.js +226 -0
- package/dist/nat/port-mapper.js.map +1 -0
- package/dist/nat/reachability.d.ts +19 -0
- package/dist/nat/reachability.js +29 -0
- package/dist/nat/reachability.js.map +1 -0
- package/dist/nat/secrets/file-store.d.ts +23 -0
- package/dist/nat/secrets/file-store.js +102 -0
- package/dist/nat/secrets/file-store.js.map +1 -0
- package/dist/nat/secrets/index.d.ts +36 -0
- package/dist/nat/secrets/index.js +56 -0
- package/dist/nat/secrets/index.js.map +1 -0
- package/dist/nat/secrets/keytar-store.d.ts +18 -0
- package/dist/nat/secrets/keytar-store.js +52 -0
- package/dist/nat/secrets/keytar-store.js.map +1 -0
- package/dist/nat/types.d.ts +120 -0
- package/dist/nat/types.js +24 -0
- package/dist/nat/types.js.map +1 -0
- package/dist/orchestrator/host-process-orchestrator.d.ts +87 -0
- package/dist/orchestrator/host-process-orchestrator.js +557 -0
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -0
- package/dist/orchestrator/index.d.ts +3 -0
- package/dist/orchestrator/index.js +2 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/orchestrator/log-rotator.d.ts +25 -0
- package/dist/orchestrator/log-rotator.js +56 -0
- package/dist/orchestrator/log-rotator.js.map +1 -0
- package/dist/orchestrator/pid-liveness.d.ts +11 -0
- package/dist/orchestrator/pid-liveness.js +26 -0
- package/dist/orchestrator/pid-liveness.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +16 -0
- package/dist/orchestrator/port-allocator.js +38 -0
- package/dist/orchestrator/port-allocator.js.map +1 -0
- package/dist/orchestrator/state-store.d.ts +32 -0
- package/dist/orchestrator/state-store.js +44 -0
- package/dist/orchestrator/state-store.js.map +1 -0
- package/dist/orchestrator/types.d.ts +85 -0
- package/dist/orchestrator/types.js +31 -0
- package/dist/orchestrator/types.js.map +1 -0
- package/dist/server/error-handler.d.ts +26 -0
- package/dist/server/error-handler.js +95 -0
- package/dist/server/error-handler.js.map +1 -0
- package/dist/server/events/bus.d.ts +17 -0
- package/dist/server/events/bus.js +36 -0
- package/dist/server/events/bus.js.map +1 -0
- package/dist/server/events/sse-route.d.ts +22 -0
- package/dist/server/events/sse-route.js +65 -0
- package/dist/server/events/sse-route.js.map +1 -0
- package/dist/server/events/types.d.ts +25 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/events/types.js.map +1 -0
- package/dist/server/index.d.ts +67 -0
- package/dist/server/index.js +149 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/origin-guard.d.ts +24 -0
- package/dist/server/origin-guard.js +72 -0
- package/dist/server/origin-guard.js.map +1 -0
- package/dist/server/routes/nat.d.ts +16 -0
- package/dist/server/routes/nat.js +42 -0
- package/dist/server/routes/nat.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +23 -0
- package/dist/server/routes/nodes.js +128 -0
- package/dist/server/routes/nodes.js.map +1 -0
- package/dist/server/routes/settings.d.ts +26 -0
- package/dist/server/routes/settings.js +97 -0
- package/dist/server/routes/settings.js.map +1 -0
- package/dist/server/routes/status.d.ts +42 -0
- package/dist/server/routes/status.js +76 -0
- package/dist/server/routes/status.js.map +1 -0
- package/dist/server/routes/trust-circle.d.ts +17 -0
- package/dist/server/routes/trust-circle.js +35 -0
- package/dist/server/routes/trust-circle.js.map +1 -0
- package/dist/server/routes/update.d.ts +16 -0
- package/dist/server/routes/update.js +29 -0
- package/dist/server/routes/update.js.map +1 -0
- package/dist/server/server.d.ts +40 -0
- package/dist/server/server.js +90 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/settings-store.d.ts +24 -0
- package/dist/server/settings-store.js +27 -0
- package/dist/server/settings-store.js.map +1 -0
- package/dist/server/static.d.ts +25 -0
- package/dist/server/static.js +177 -0
- package/dist/server/static.js.map +1 -0
- package/dist/ui/assets/index-BwU5mJGF.css +1 -0
- package/dist/ui/assets/index-C9uChk2P.js +14 -0
- package/dist/ui/assets/index-C9uChk2P.js.map +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/update/apply.d.ts +37 -0
- package/dist/update/apply.js +102 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/index.d.ts +101 -0
- package/dist/update/index.js +228 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/manifest.d.ts +48 -0
- package/dist/update/manifest.js +152 -0
- package/dist/update/manifest.js.map +1 -0
- package/dist/update/release-key.d.ts +24 -0
- package/dist/update/release-key.js +52 -0
- package/dist/update/release-key.js.map +1 -0
- package/dist/update/store.d.ts +22 -0
- package/dist/update/store.js +81 -0
- package/dist/update/store.js.map +1 -0
- package/dist/update/types.d.ts +90 -0
- package/dist/update/types.js +22 -0
- package/dist/update/types.js.map +1 -0
- package/dist/update/version.d.ts +18 -0
- package/dist/update/version.js +76 -0
- package/dist/update/version.js.map +1 -0
- package/package.json +80 -0
- package/service/README.md +86 -0
- package/service/cadre-host.service.tmpl +16 -0
- package/service/com.serfab.cadre-host.plist.tmpl +35 -0
- package/service/install-service.ps1 +98 -0
- package/service/uninstall-service.ps1 +19 -0
- package/src/__tests__/cli-invite.smoke.test.ts +187 -0
- package/src/__tests__/cli-nat.smoke.test.ts +207 -0
- package/src/__tests__/cli.smoke.test.ts +41 -0
- package/src/__tests__/orchestrator.test.ts +570 -0
- package/src/auth/__tests__/duration.test.ts +28 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +128 -0
- package/src/auth/__tests__/trust-circle-store.test.ts +115 -0
- package/src/auth/__tests__/trust-circle.test.ts +334 -0
- package/src/auth/duration.ts +33 -0
- package/src/auth/index.ts +19 -0
- package/src/auth/trust-circle-store.ts +155 -0
- package/src/auth/trust-circle.ts +304 -0
- package/src/auth/types.ts +96 -0
- package/src/bin/host.ts +905 -0
- package/src/index.ts +167 -0
- package/src/installer/__tests__/config.test.ts +86 -0
- package/src/installer/__tests__/identity.test.ts +34 -0
- package/src/installer/__tests__/installer.smoke.test.ts +124 -0
- package/src/installer/__tests__/paths.test.ts +47 -0
- package/src/installer/__tests__/service-host.test.ts +62 -0
- package/src/installer/__tests__/wizard.test.ts +60 -0
- package/src/installer/browser.ts +42 -0
- package/src/installer/config.ts +153 -0
- package/src/installer/identity.ts +49 -0
- package/src/installer/index.ts +303 -0
- package/src/installer/paths.ts +109 -0
- package/src/installer/platform.ts +27 -0
- package/src/installer/service-host/index.ts +21 -0
- package/src/installer/service-host/launchd.ts +90 -0
- package/src/installer/service-host/nssm.ts +100 -0
- package/src/installer/service-host/systemd.ts +88 -0
- package/src/installer/service-host/template.ts +21 -0
- package/src/installer/service-host/types.ts +48 -0
- package/src/installer/wizard.ts +92 -0
- package/src/nat/__tests__/address-resolver.test.ts +82 -0
- package/src/nat/__tests__/ddns-duckdns.test.ts +83 -0
- package/src/nat/__tests__/ddns-updater.test.ts +253 -0
- package/src/nat/__tests__/external-ip.test.ts +124 -0
- package/src/nat/__tests__/nat-service.test.ts +305 -0
- package/src/nat/__tests__/nat-store.test.ts +93 -0
- package/src/nat/__tests__/port-mapper.test.ts +191 -0
- package/src/nat/__tests__/reachability.test.ts +24 -0
- package/src/nat/__tests__/secrets.test.ts +156 -0
- package/src/nat/address-resolver.ts +51 -0
- package/src/nat/ddns/duckdns.ts +76 -0
- package/src/nat/ddns/index.ts +55 -0
- package/src/nat/ddns/updater.ts +243 -0
- package/src/nat/external-ip.ts +154 -0
- package/src/nat/index.ts +71 -0
- package/src/nat/nat-service.ts +375 -0
- package/src/nat/nat-store.ts +154 -0
- package/src/nat/port-mapper.ts +329 -0
- package/src/nat/reachability.ts +34 -0
- package/src/nat/secrets/file-store.ts +130 -0
- package/src/nat/secrets/index.ts +82 -0
- package/src/nat/secrets/keytar-store.ts +67 -0
- package/src/nat/types.ts +157 -0
- package/src/orchestrator/host-process-orchestrator.ts +626 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/log-rotator.ts +53 -0
- package/src/orchestrator/pid-liveness.ts +24 -0
- package/src/orchestrator/port-allocator.ts +43 -0
- package/src/orchestrator/state-store.ts +72 -0
- package/src/orchestrator/types.ts +95 -0
- package/src/server/__tests__/bus.test.ts +38 -0
- package/src/server/__tests__/error-handler.test.ts +96 -0
- package/src/server/__tests__/nodes-route.test.ts +179 -0
- package/src/server/__tests__/origin-guard.test.ts +72 -0
- package/src/server/__tests__/publishers.test.ts +216 -0
- package/src/server/__tests__/server.smoke.test.ts +178 -0
- package/src/server/__tests__/settings-route.test.ts +177 -0
- package/src/server/__tests__/sse-route.test.ts +119 -0
- package/src/server/__tests__/status-route.test.ts +113 -0
- package/src/server/error-handler.ts +108 -0
- package/src/server/events/bus.ts +42 -0
- package/src/server/events/sse-route.ts +81 -0
- package/src/server/events/types.ts +22 -0
- package/src/server/index.ts +209 -0
- package/src/server/origin-guard.ts +84 -0
- package/src/server/routes/nat.ts +63 -0
- package/src/server/routes/nodes.ts +147 -0
- package/src/server/routes/settings.ts +115 -0
- package/src/server/routes/status.ts +108 -0
- package/src/server/routes/trust-circle.ts +49 -0
- package/src/server/routes/update.ts +43 -0
- package/src/server/server.ts +122 -0
- package/src/server/settings-store.ts +37 -0
- package/src/server/static.ts +191 -0
- package/src/update/__tests__/apply.test.ts +101 -0
- package/src/update/__tests__/manifest.test.ts +93 -0
- package/src/update/__tests__/store.test.ts +81 -0
- package/src/update/__tests__/update-service.integration.test.ts +221 -0
- package/src/update/__tests__/version.test.ts +31 -0
- package/src/update/apply.ts +148 -0
- package/src/update/index.ts +303 -0
- package/src/update/manifest.ts +172 -0
- package/src/update/release-key.ts +55 -0
- package/src/update/store.ts +101 -0
- package/src/update/types.ts +109 -0
- 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,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
|
+
}
|