rollbridge 0.1.13 → 0.1.15
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 +6 -5
- package/compose.yml +17 -0
- package/docs/cli.md +28 -8
- package/docs/troubleshooting.md +18 -0
- package/package.json +1 -1
- package/src/cli.js +57 -18
- package/src/daemon-runtime.js +316 -0
- package/src/daemon.js +25 -4
- package/test/completion.test.js +1 -0
- package/test/daemon-bootstrap.test.js +40 -7
- package/test/daemon-runtime.test.js +90 -0
- package/test/fixtures/dummy-app.js +1 -1
- package/test/package-metadata.test.js +39 -0
- package/test/release-runtime-retention.test.js +357 -0
- package/test/rollbridge.test.js +3 -1
- package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +0 -28
- package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +0 -13
- package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/plan.md +0 -9
- package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +0 -3
- package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +0 -25
- package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +0 -14
- package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +0 -3
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +0 -38
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +0 -1
- package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +0 -1
package/README.md
CHANGED
|
@@ -409,11 +409,12 @@ rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
|
409
409
|
```
|
|
410
410
|
|
|
411
411
|
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
412
|
-
paths. Rollbridge binds its proxy
|
|
413
|
-
|
|
414
|
-
only processes started by that attempt and exits non-zero;
|
|
415
|
-
from a previous daemon are reported as orphans and are never
|
|
416
|
-
implicitly; their live PID records remain in `statePath` for
|
|
412
|
+
paths. Rollbridge binds its proxy, activates the release through the normal
|
|
413
|
+
deploy path, then exposes the control socket and stays foreground. A failed
|
|
414
|
+
activation stops only processes started by that attempt and exits non-zero;
|
|
415
|
+
persisted processes from a previous daemon are reported as orphans and are never
|
|
416
|
+
recovered or killed implicitly; their live PID records remain in `statePath` for
|
|
417
|
+
explicit recovery.
|
|
417
418
|
|
|
418
419
|
Start the daemon only when it is not already running:
|
|
419
420
|
|
package/compose.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Canonical Rollbridge development service.
|
|
2
|
+
name: rollbridge
|
|
3
|
+
|
|
4
|
+
services:
|
|
5
|
+
dev:
|
|
6
|
+
image: threadwire-dev:local
|
|
7
|
+
entrypoint: ["/bin/sh", "-c"]
|
|
8
|
+
init: true
|
|
9
|
+
user: "1000:1000"
|
|
10
|
+
working_dir: /home/dev/rollbridge
|
|
11
|
+
environment:
|
|
12
|
+
GH_CONFIG_DIR: /home/dev/.config/gh
|
|
13
|
+
HOME: /home/dev
|
|
14
|
+
volumes:
|
|
15
|
+
- ${DEV_HOME_PATH:-/home/dev}:/home/dev
|
|
16
|
+
- ${GH_CONFIG_SOURCE_PATH:?Set GH_CONFIG_SOURCE_PATH in .env}:/home/dev/.config/gh:ro
|
|
17
|
+
command: ["exec sleep infinity"]
|
package/docs/cli.md
CHANGED
|
@@ -35,11 +35,12 @@ such as systemd (see `examples/rollbridge.service`).
|
|
|
35
35
|
|
|
36
36
|
For boot/crash recovery, pass an explicit absolute `--config` and all three
|
|
37
37
|
release options together. Rollbridge validates every bootstrap input before it
|
|
38
|
-
binds listeners or starts processes, then binds the
|
|
39
|
-
exact release through the
|
|
38
|
+
binds listeners or starts processes, then binds the proxy and activates that
|
|
39
|
+
exact release through the normal deploy path: services,
|
|
40
40
|
companions, the proxied process and health check, traffic switching, singletons,
|
|
41
|
-
and service-template refresh.
|
|
42
|
-
|
|
41
|
+
and service-template refresh. Only after activation succeeds does it expose the
|
|
42
|
+
control socket, preventing another deployment from overlapping bootstrap. It
|
|
43
|
+
then remains in the foreground with the normal signal behavior.
|
|
43
44
|
|
|
44
45
|
Bootstrap paths must be absolute and normalized, the release path must be an
|
|
45
46
|
accessible directory, and release id/revision values accept letters, numbers,
|
|
@@ -47,10 +48,10 @@ dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
|
|
|
47
48
|
or number). Supplying only some bootstrap options, or an invalid value, exits
|
|
48
49
|
non-zero before listeners start. Activation failure emits a structured
|
|
49
50
|
`bootstrap activation failed` event, cleans up processes owned by that attempt,
|
|
50
|
-
and exits non-zero without
|
|
51
|
-
a previous daemon remain advisory orphans:
|
|
52
|
-
never signals those processes, and retains
|
|
53
|
-
for explicit recovery.
|
|
51
|
+
and exits non-zero without exposing the control socket or inventing an active
|
|
52
|
+
release. `statePath` entries from a previous daemon remain advisory orphans:
|
|
53
|
+
bootstrap never runs recovery and never signals those processes, and retains
|
|
54
|
+
their live PID records in `statePath` for explicit recovery.
|
|
54
55
|
|
|
55
56
|
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
56
57
|
and waits for control-socket deployments.
|
|
@@ -61,6 +62,7 @@ and waits for control-socket deployments.
|
|
|
61
62
|
rollbridge ensure-daemon [--config <path>]
|
|
62
63
|
[--daemon-log-path <path>]
|
|
63
64
|
[--daemon-pid-path <path>]
|
|
65
|
+
[--daemon-runtime-path <path>]
|
|
64
66
|
[--daemon-start-timeout-ms <ms>]
|
|
65
67
|
```
|
|
66
68
|
|
|
@@ -68,11 +70,24 @@ Starts the daemon as a detached process **only if** the control socket is not
|
|
|
68
70
|
already accepting commands, waits until it responds, then prints the daemon
|
|
69
71
|
status JSON. Idempotent — safe to call before every deploy.
|
|
70
72
|
|
|
73
|
+
Before starting a detached daemon, Rollbridge atomically copies its runtime code
|
|
74
|
+
and production dependency closure into a content-addressed directory outside
|
|
75
|
+
the invoking release. This keeps the long-lived daemon valid when deploy
|
|
76
|
+
retention removes that release. A responsive daemon is reused only when its
|
|
77
|
+
runtime identity matches the invoking Rollbridge installation; a legacy or
|
|
78
|
+
mismatched daemon causes the command to fail before any deploy is sent. Stop and
|
|
79
|
+
restart such a daemon explicitly during a safe maintenance handoff.
|
|
80
|
+
|
|
71
81
|
- `--daemon-log-path <path>` — file the detached daemon's stdout/stderr is
|
|
72
82
|
appended to. Default: `/tmp/rollbridge-<application>.log`. See
|
|
73
83
|
[`logging.md`](logging.md) for the log format and rotation guidance.
|
|
74
84
|
- `--daemon-pid-path <path>` — file the detached daemon's PID is written to.
|
|
75
85
|
Default: `/tmp/rollbridge-<application>.pid`.
|
|
86
|
+
- `--daemon-runtime-path <path>` — parent directory for content-addressed daemon
|
|
87
|
+
runtime snapshots. Default:
|
|
88
|
+
`/tmp/rollbridge-<user-id>-<application-hash>-runtime`. The directory must be owned
|
|
89
|
+
by the current user and must not be group/world writable; preparation or
|
|
90
|
+
validation failure aborts before daemon startup or deploy handoff.
|
|
76
91
|
- `--daemon-start-timeout-ms <ms>` — how long to wait for the daemon to accept
|
|
77
92
|
control commands before failing. Default: `10000`.
|
|
78
93
|
|
|
@@ -86,6 +101,7 @@ rollbridge deploy --release-path <path>
|
|
|
86
101
|
[--ensure-daemon]
|
|
87
102
|
[--daemon-log-path <path>]
|
|
88
103
|
[--daemon-pid-path <path>]
|
|
104
|
+
[--daemon-runtime-path <path>]
|
|
89
105
|
[--daemon-start-timeout-ms <ms>]
|
|
90
106
|
```
|
|
91
107
|
|
|
@@ -154,6 +170,10 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
|
|
|
154
170
|
`lastMemoryRestartAt`, and `children` (the process tree: each group member's
|
|
155
171
|
`pid`, `command`, and `rssBytes`).
|
|
156
172
|
|
|
173
|
+
`daemonRuntime` identifies the immutable Rollbridge runtime serving the proxy:
|
|
174
|
+
its runtime `format`, package `version`, content `digest`, and absolute `path`.
|
|
175
|
+
`ensure-daemon` uses this attestation before reusing a responsive daemon.
|
|
176
|
+
|
|
157
177
|
When [`statePath`](config.md#statepath) is configured, status also includes an
|
|
158
178
|
`orphans` array: managed processes from a **previous** daemon that are still
|
|
159
179
|
alive (`id`, `pid`, `releaseId`) — for example after the daemon restarted but its
|
package/docs/troubleshooting.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Troubleshooting
|
|
2
2
|
|
|
3
|
+
## Legacy or mismatched daemon runtime
|
|
4
|
+
|
|
5
|
+
**Symptom.** `deploy --ensure-daemon` or `ensure-daemon` reports that the
|
|
6
|
+
running daemon has a legacy or mismatched runtime and confirms that the deploy
|
|
7
|
+
was not sent.
|
|
8
|
+
|
|
9
|
+
**Cause.** A daemon already owns the stable proxy/control socket, but it cannot
|
|
10
|
+
attest to the same immutable Rollbridge runtime as the CLI preparing the deploy.
|
|
11
|
+
Rollbridge does not silently restart it because rebinding the proxy could cause
|
|
12
|
+
downtime or abandon managed processes.
|
|
13
|
+
|
|
14
|
+
**Fix.** Keep the current release active, explicitly stop and restart the daemon
|
|
15
|
+
with the intended Rollbridge installation during a safe handoff, then retry the
|
|
16
|
+
deploy. If durable runtime preparation itself fails, check permissions for
|
|
17
|
+
`--daemon-runtime-path` (default
|
|
18
|
+
`/tmp/rollbridge-<user-id>-<application-hash>-runtime`) before retrying. The directory
|
|
19
|
+
must be private to the invoking user.
|
|
20
|
+
|
|
3
21
|
Start with these three commands — they diagnose most problems without guessing:
|
|
4
22
|
|
|
5
23
|
- `rollbridge validate` — config errors, with an example fix for each.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs"
|
|
4
4
|
import fsPromises from "node:fs/promises"
|
|
5
|
+
import {createHash} from "node:crypto"
|
|
5
6
|
import path from "node:path"
|
|
6
7
|
import {spawn} from "node:child_process"
|
|
7
8
|
import {Command} from "commander"
|
|
8
9
|
import RollbridgeDaemon from "./daemon.js"
|
|
10
|
+
import {loadDaemonRuntimeIdentity, prepareDaemonRuntime} from "./daemon-runtime.js"
|
|
9
11
|
import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
|
|
10
12
|
import {runEnvironmentChecks, runReleaseChecks} from "./doctor.js"
|
|
11
13
|
import {predeployCleanup} from "./predeploy-cleanup.js"
|
|
@@ -37,9 +39,10 @@ export async function runCli(argv) {
|
|
|
37
39
|
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
38
40
|
const configPath = await resolveConfigPath(options.config)
|
|
39
41
|
const config = await loadConfig(configPath)
|
|
40
|
-
const
|
|
42
|
+
const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
|
|
43
|
+
const daemon = new RollbridgeDaemon({config, configPath, runtime})
|
|
41
44
|
|
|
42
|
-
await daemon.start()
|
|
45
|
+
await daemon.start({exposeControl: !bootstrap})
|
|
43
46
|
|
|
44
47
|
const shutdown = async () => {
|
|
45
48
|
await daemon.shutdown()
|
|
@@ -52,6 +55,7 @@ export async function runCli(argv) {
|
|
|
52
55
|
if (bootstrap) {
|
|
53
56
|
try {
|
|
54
57
|
await daemon.deploy(bootstrap)
|
|
58
|
+
await daemon.exposeControl()
|
|
55
59
|
} catch {
|
|
56
60
|
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
|
|
57
61
|
await daemon.shutdown()
|
|
@@ -70,6 +74,7 @@ export async function runCli(argv) {
|
|
|
70
74
|
.option("--ensure-daemon", "Start the Rollbridge daemon if it is not already running")
|
|
71
75
|
.option("--daemon-log-path <path>", "Log path used when --ensure-daemon starts the daemon")
|
|
72
76
|
.option("--daemon-pid-path <path>", "PID file path used when --ensure-daemon starts the daemon")
|
|
77
|
+
.option("--daemon-runtime-path <path>", "Directory for durable daemon runtime snapshots")
|
|
73
78
|
.option("--daemon-start-timeout-ms <ms>", "How long to wait for an ensured daemon to accept control commands")
|
|
74
79
|
.action(async (options) => {
|
|
75
80
|
const configPath = await resolveConfigPath(options.config)
|
|
@@ -77,11 +82,11 @@ export async function runCli(argv) {
|
|
|
77
82
|
|
|
78
83
|
if (options.ensureDaemon) {
|
|
79
84
|
await ensureDaemonRunning({
|
|
80
|
-
argv,
|
|
81
85
|
config,
|
|
82
86
|
configPath,
|
|
83
87
|
logPath: options.daemonLogPath,
|
|
84
88
|
pidPath: options.daemonPidPath,
|
|
89
|
+
runtimePath: options.daemonRuntimePath,
|
|
85
90
|
timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
|
|
86
91
|
})
|
|
87
92
|
}
|
|
@@ -124,16 +129,17 @@ export async function runCli(argv) {
|
|
|
124
129
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
125
130
|
.option("--daemon-log-path <path>", "Daemon log path")
|
|
126
131
|
.option("--daemon-pid-path <path>", "Daemon PID file path")
|
|
132
|
+
.option("--daemon-runtime-path <path>", "Directory for durable daemon runtime snapshots")
|
|
127
133
|
.option("--daemon-start-timeout-ms <ms>", "How long to wait for the daemon to accept control commands")
|
|
128
134
|
.action(async (options) => {
|
|
129
135
|
const configPath = await resolveConfigPath(options.config)
|
|
130
136
|
const config = await loadConfig(configPath)
|
|
131
137
|
const response = await ensureDaemonRunning({
|
|
132
|
-
argv,
|
|
133
138
|
config,
|
|
134
139
|
configPath,
|
|
135
140
|
logPath: options.daemonLogPath,
|
|
136
141
|
pidPath: options.daemonPidPath,
|
|
142
|
+
runtimePath: options.daemonRuntimePath,
|
|
137
143
|
timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
|
|
138
144
|
})
|
|
139
145
|
|
|
@@ -757,27 +763,34 @@ async function validateDaemonBootstrapOptions(options) {
|
|
|
757
763
|
/**
|
|
758
764
|
* Starts a daemon when needed and waits until it accepts status commands.
|
|
759
765
|
* @param {object} args - Options.
|
|
760
|
-
* @param {string[]} args.argv - Original CLI argv.
|
|
761
766
|
* @param {import("./config.js").RollbridgeConfig} args.config - Loaded config.
|
|
762
767
|
* @param {string} args.configPath - Config path.
|
|
763
768
|
* @param {string | undefined} args.logPath - Optional daemon log path.
|
|
764
769
|
* @param {string | undefined} args.pidPath - Optional daemon PID path.
|
|
770
|
+
* @param {string | undefined} args.runtimePath - Optional durable runtime directory.
|
|
765
771
|
* @param {number} args.timeoutMs - Startup timeout.
|
|
766
772
|
* @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
|
|
767
773
|
*/
|
|
768
|
-
async function ensureDaemonRunning({
|
|
774
|
+
async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtimePath, timeoutMs}) {
|
|
775
|
+
const runtime = await prepareDaemonRuntime(runtimePath || defaultDaemonRuntimePath(config))
|
|
769
776
|
const existingStatus = await daemonStatus(config)
|
|
770
777
|
|
|
771
|
-
if (existingStatus)
|
|
778
|
+
if (existingStatus) {
|
|
779
|
+
assertCompatibleDaemonRuntime(existingStatus, runtime)
|
|
780
|
+
return existingStatus
|
|
781
|
+
}
|
|
772
782
|
|
|
773
783
|
await startDaemonProcess({
|
|
774
|
-
argv,
|
|
775
784
|
configPath,
|
|
776
785
|
logPath: logPath || defaultDaemonLogPath(config),
|
|
777
|
-
pidPath: pidPath || defaultDaemonPidPath(config)
|
|
786
|
+
pidPath: pidPath || defaultDaemonPidPath(config),
|
|
787
|
+
runtime
|
|
778
788
|
})
|
|
779
789
|
|
|
780
|
-
|
|
790
|
+
const startedStatus = await waitForDaemonStatus(config, timeoutMs)
|
|
791
|
+
|
|
792
|
+
assertCompatibleDaemonRuntime(startedStatus, runtime)
|
|
793
|
+
return startedStatus
|
|
781
794
|
}
|
|
782
795
|
|
|
783
796
|
/**
|
|
@@ -804,17 +817,13 @@ async function daemonStatus(config) {
|
|
|
804
817
|
/**
|
|
805
818
|
* Starts the foreground daemon command as a detached child.
|
|
806
819
|
* @param {object} args - Options.
|
|
807
|
-
* @param {string[]} args.argv - Original CLI argv.
|
|
808
820
|
* @param {string} args.configPath - Config path.
|
|
809
821
|
* @param {string} args.logPath - Log file path.
|
|
810
822
|
* @param {string} args.pidPath - PID file path.
|
|
823
|
+
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} args.runtime - Prepared runtime.
|
|
811
824
|
* @returns {Promise<void>} Resolves after the child has been spawned.
|
|
812
825
|
*/
|
|
813
|
-
async function startDaemonProcess({
|
|
814
|
-
const binPath = argv[1] || process.argv[1]
|
|
815
|
-
|
|
816
|
-
if (!binPath) throw new Error("Unable to determine Rollbridge CLI path for daemon startup")
|
|
817
|
-
|
|
826
|
+
async function startDaemonProcess({configPath, logPath, pidPath, runtime}) {
|
|
818
827
|
await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
|
|
819
828
|
await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
|
|
820
829
|
|
|
@@ -822,9 +831,9 @@ async function startDaemonProcess({argv, configPath, logPath, pidPath}) {
|
|
|
822
831
|
const stderrFd = fs.openSync(logPath, "a")
|
|
823
832
|
|
|
824
833
|
try {
|
|
825
|
-
const child = spawn(process.execPath, [
|
|
834
|
+
const child = spawn(process.execPath, [path.join(runtime.path, "bin", "rollbridge"), "daemon", "--config", configPath], {
|
|
826
835
|
detached: true,
|
|
827
|
-
env: process.env,
|
|
836
|
+
env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtime.path, "runtime.json")},
|
|
828
837
|
stdio: ["ignore", stdoutFd, stderrFd]
|
|
829
838
|
})
|
|
830
839
|
|
|
@@ -882,6 +891,36 @@ function defaultDaemonPidPath(config) {
|
|
|
882
891
|
return `/tmp/rollbridge-${config.application}.pid`
|
|
883
892
|
}
|
|
884
893
|
|
|
894
|
+
/**
|
|
895
|
+
* @param {import("./config.js").RollbridgeConfig} config - Loaded config.
|
|
896
|
+
* @returns {string} Default durable daemon runtime directory.
|
|
897
|
+
*/
|
|
898
|
+
function defaultDaemonRuntimePath(config) {
|
|
899
|
+
const userId = typeof process.getuid === "function" ? process.getuid() : "user"
|
|
900
|
+
const applicationHash = createHash("sha256").update(config.application).digest("hex").slice(0, 16)
|
|
901
|
+
|
|
902
|
+
return `/tmp/rollbridge-${userId}-${applicationHash}-runtime`
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Refuses to hand a deploy to a daemon whose immutable runtime does not match this CLI.
|
|
907
|
+
* @param {Record<string, import("./json.js").JsonValue>} status - Existing daemon status.
|
|
908
|
+
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} expected - Expected runtime.
|
|
909
|
+
* @returns {void}
|
|
910
|
+
*/
|
|
911
|
+
function assertCompatibleDaemonRuntime(status, expected) {
|
|
912
|
+
const runtime = status.daemonRuntime
|
|
913
|
+
const compatible = runtime && typeof runtime === "object" && !Array.isArray(runtime) &&
|
|
914
|
+
runtime.format === expected.format && runtime.version === expected.version && runtime.digest === expected.digest
|
|
915
|
+
|
|
916
|
+
if (compatible) return
|
|
917
|
+
|
|
918
|
+
throw new Error(
|
|
919
|
+
"The running Rollbridge daemon has a legacy or mismatched runtime. " +
|
|
920
|
+
"The deploy was not sent. Keep the current release active, then explicitly stop and restart the daemon with this Rollbridge installation before retrying."
|
|
921
|
+
)
|
|
922
|
+
}
|
|
923
|
+
|
|
885
924
|
/**
|
|
886
925
|
* @param {string | undefined} value - Raw timeout value.
|
|
887
926
|
* @returns {number} Timeout in milliseconds.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createHash} from "node:crypto"
|
|
4
|
+
import {execFile} from "node:child_process"
|
|
5
|
+
import fs from "node:fs/promises"
|
|
6
|
+
import {createRequire} from "node:module"
|
|
7
|
+
import os from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import {promisify} from "node:util"
|
|
10
|
+
import {fileURLToPath} from "node:url"
|
|
11
|
+
|
|
12
|
+
const RUNTIME_FORMAT = 1
|
|
13
|
+
const execFileAsync = promisify(execFile)
|
|
14
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{digest: string, format: number, path: string, version: string}} DaemonRuntimeIdentity
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Atomically prepares a content-addressed Rollbridge runtime outside the package tree.
|
|
22
|
+
* @param {string} basePath - Stable parent directory for runtime snapshots.
|
|
23
|
+
* @returns {Promise<DaemonRuntimeIdentity>} Prepared runtime identity.
|
|
24
|
+
*/
|
|
25
|
+
export async function prepareDaemonRuntime(basePath) {
|
|
26
|
+
await fs.mkdir(basePath, {mode: 0o700, recursive: true})
|
|
27
|
+
await validateRuntimeBase(basePath)
|
|
28
|
+
|
|
29
|
+
const stagingPath = await fs.mkdtemp(path.join(basePath, ".prepare-"))
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await copyPackageClosure(packageRoot, stagingPath, new Set(), true)
|
|
33
|
+
const digest = await directoryDigest(stagingPath)
|
|
34
|
+
const version = await packageVersion(stagingPath)
|
|
35
|
+
const runtimePath = path.join(basePath, digest)
|
|
36
|
+
const identity = {digest, format: RUNTIME_FORMAT, path: runtimePath, version}
|
|
37
|
+
|
|
38
|
+
await fs.writeFile(path.join(stagingPath, "runtime.json"), `${JSON.stringify(identity, null, 2)}\n`, {mode: 0o600})
|
|
39
|
+
await validateRuntime(stagingPath, identity)
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await fs.rename(stagingPath, runtimePath)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const fileError = /** @type {Error & {code?: string}} */ (error)
|
|
45
|
+
|
|
46
|
+
if (!hasCode(fileError, "EEXIST") && !hasCode(fileError, "ENOTEMPTY")) throw error
|
|
47
|
+
await validateRuntime(runtimePath, identity)
|
|
48
|
+
await fs.rm(stagingPath, {force: true, recursive: true})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await validateRuntime(runtimePath, identity)
|
|
52
|
+
return identity
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await fs.rm(stagingPath, {force: true, recursive: true}).catch(() => {})
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rejects a runtime path beneath any ancestor that another local user could replace.
|
|
61
|
+
* @param {string} basePath - Runtime parent directory.
|
|
62
|
+
* @returns {Promise<void>} Resolves when the full path is safely anchored.
|
|
63
|
+
*/
|
|
64
|
+
async function validateRuntimeBase(basePath) {
|
|
65
|
+
const absolutePath = path.resolve(basePath)
|
|
66
|
+
const rootPath = path.parse(absolutePath).root
|
|
67
|
+
const relativeParts = path.relative(rootPath, absolutePath).split(path.sep).filter(Boolean)
|
|
68
|
+
const paths = [rootPath]
|
|
69
|
+
|
|
70
|
+
for (const part of relativeParts) paths.push(path.join(paths.at(-1) || rootPath, part))
|
|
71
|
+
|
|
72
|
+
for (const candidatePath of paths) {
|
|
73
|
+
const stats = await fs.lstat(candidatePath)
|
|
74
|
+
|
|
75
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
76
|
+
throw new Error(`Every Rollbridge daemon runtime path component must be a real directory: ${candidatePath}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof process.getuid !== "function") continue
|
|
80
|
+
|
|
81
|
+
const ownedByTrustedUser = stats.uid === 0 || stats.uid === process.getuid()
|
|
82
|
+
const writableByOthers = (stats.mode & 0o022) !== 0
|
|
83
|
+
const sticky = (stats.mode & 0o1000) !== 0
|
|
84
|
+
|
|
85
|
+
if (candidatePath !== absolutePath && !ownedByTrustedUser) {
|
|
86
|
+
throw new Error(`Rollbridge daemon runtime ancestor must be owned by root or the current user: ${candidatePath}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (candidatePath !== absolutePath && writableByOthers && !sticky) {
|
|
90
|
+
throw new Error(`Rollbridge daemon runtime ancestor must be sticky or not writable by group or other users: ${candidatePath}`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stats = await fs.lstat(absolutePath)
|
|
95
|
+
|
|
96
|
+
if (typeof process.getuid === "function" && stats.uid !== process.getuid()) {
|
|
97
|
+
throw new Error(`Rollbridge daemon runtime path must be owned by the current user: ${absolutePath}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ((stats.mode & 0o022) !== 0) {
|
|
101
|
+
throw new Error(`Rollbridge daemon runtime path must not be writable by group or other users: ${absolutePath}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reads and validates the runtime identity supplied to a detached daemon.
|
|
107
|
+
* @param {string | undefined} manifestPath - Runtime manifest path.
|
|
108
|
+
* @returns {Promise<DaemonRuntimeIdentity>} Validated identity.
|
|
109
|
+
*/
|
|
110
|
+
export async function loadDaemonRuntimeIdentity(manifestPath) {
|
|
111
|
+
if (!manifestPath) return await currentPackageIdentity()
|
|
112
|
+
|
|
113
|
+
const runtimePath = path.dirname(manifestPath)
|
|
114
|
+
const identity = parseIdentity(JSON.parse(await fs.readFile(manifestPath, "utf8")))
|
|
115
|
+
|
|
116
|
+
if (path.resolve(identity.path) !== path.resolve(runtimePath)) {
|
|
117
|
+
throw new Error(`Daemon runtime manifest path mismatch: expected ${runtimePath}, got ${identity.path}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await validateRuntime(runtimePath, identity)
|
|
121
|
+
return identity
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @returns {Promise<DaemonRuntimeIdentity>} Identity of the package running a foreground daemon.
|
|
126
|
+
*/
|
|
127
|
+
export async function currentPackageIdentity() {
|
|
128
|
+
return {
|
|
129
|
+
digest: await packageClosureDigest(packageRoot),
|
|
130
|
+
format: RUNTIME_FORMAT,
|
|
131
|
+
path: packageRoot,
|
|
132
|
+
version: await packageVersion(packageRoot)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} sourcePath - Source package directory.
|
|
138
|
+
* @param {string} destinationPath - Destination package directory.
|
|
139
|
+
* @param {Set<string>} ancestry - Real package paths in the current dependency chain.
|
|
140
|
+
* @param {boolean} root - Whether this is Rollbridge itself.
|
|
141
|
+
* @returns {Promise<void>} Resolves when copied.
|
|
142
|
+
*/
|
|
143
|
+
async function copyPackageClosure(sourcePath, destinationPath, ancestry, root) {
|
|
144
|
+
const realSourcePath = await fs.realpath(sourcePath)
|
|
145
|
+
|
|
146
|
+
if (ancestry.has(realSourcePath)) return
|
|
147
|
+
|
|
148
|
+
const nextAncestry = new Set(ancestry).add(realSourcePath)
|
|
149
|
+
const packageJson = JSON.parse(await fs.readFile(path.join(realSourcePath, "package.json"), "utf8"))
|
|
150
|
+
|
|
151
|
+
await fs.mkdir(destinationPath, {recursive: true})
|
|
152
|
+
|
|
153
|
+
if (root) {
|
|
154
|
+
for (const entry of ["bin", "src", "package.json"]) {
|
|
155
|
+
await fs.cp(path.join(realSourcePath, entry), path.join(destinationPath, entry), {dereference: true, recursive: true})
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
await fs.cp(realSourcePath, destinationPath, {
|
|
159
|
+
dereference: true,
|
|
160
|
+
filter: (source) => path.relative(realSourcePath, source).split(path.sep)[0] !== "node_modules",
|
|
161
|
+
recursive: true
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const dependency of Object.keys(packageJson.dependencies || {}).sort()) {
|
|
166
|
+
const dependencySource = await resolvePackageRoot(realSourcePath, dependency)
|
|
167
|
+
const dependencyDestination = path.join(destinationPath, "node_modules", ...dependency.split("/"))
|
|
168
|
+
|
|
169
|
+
await copyPackageClosure(dependencySource, dependencyDestination, nextAncestry, false)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} parentPackagePath - Requiring package root.
|
|
175
|
+
* @param {string} dependency - Dependency package name.
|
|
176
|
+
* @returns {Promise<string>} Resolved dependency package root.
|
|
177
|
+
*/
|
|
178
|
+
async function resolvePackageRoot(parentPackagePath, dependency) {
|
|
179
|
+
const require = createRequire(path.join(parentPackagePath, "package.json"))
|
|
180
|
+
let candidate = path.dirname(require.resolve(dependency))
|
|
181
|
+
|
|
182
|
+
while (candidate !== path.dirname(candidate)) {
|
|
183
|
+
try {
|
|
184
|
+
const metadata = JSON.parse(await fs.readFile(path.join(candidate, "package.json"), "utf8"))
|
|
185
|
+
|
|
186
|
+
if (metadata.name === dependency) return candidate
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!hasCode(/** @type {Error & {code?: string}} */ (error), "ENOENT")) throw error
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
candidate = path.dirname(candidate)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw new Error(`Unable to resolve package root for Rollbridge runtime dependency ${dependency}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} runtimePath - Runtime directory.
|
|
199
|
+
* @param {DaemonRuntimeIdentity} identity - Expected identity.
|
|
200
|
+
* @returns {Promise<void>} Resolves when valid.
|
|
201
|
+
*/
|
|
202
|
+
async function validateRuntime(runtimePath, identity) {
|
|
203
|
+
const manifestIdentity = parseIdentity(JSON.parse(await fs.readFile(path.join(runtimePath, "runtime.json"), "utf8")))
|
|
204
|
+
const actualDigest = await directoryDigest(runtimePath, new Set(["runtime.json"]))
|
|
205
|
+
const actualVersion = await packageVersion(runtimePath)
|
|
206
|
+
const manifestMatches = manifestIdentity.format === identity.format && manifestIdentity.digest === identity.digest &&
|
|
207
|
+
manifestIdentity.version === identity.version && path.resolve(manifestIdentity.path) === path.resolve(identity.path)
|
|
208
|
+
|
|
209
|
+
if (!manifestMatches || identity.format !== RUNTIME_FORMAT || actualDigest !== identity.digest || actualVersion !== identity.version) {
|
|
210
|
+
throw new Error(`Rollbridge daemon runtime validation failed at ${runtimePath}`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await execFileAsync(process.execPath, [path.join(runtimePath, "bin", "rollbridge"), "--help"], {
|
|
214
|
+
env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtimePath, "runtime.json")},
|
|
215
|
+
timeout: 10000
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string} sourcePath - Package root.
|
|
221
|
+
* @returns {Promise<string>} Closure digest.
|
|
222
|
+
*/
|
|
223
|
+
async function packageClosureDigest(sourcePath) {
|
|
224
|
+
const temporaryPath = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-digest-"))
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await copyPackageClosure(sourcePath, temporaryPath, new Set(), true)
|
|
228
|
+
return await directoryDigest(temporaryPath)
|
|
229
|
+
} finally {
|
|
230
|
+
await fs.rm(temporaryPath, {force: true, recursive: true})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {string} rootPath - Directory to hash.
|
|
236
|
+
* @param {Set<string>} [ignored] - Root-relative paths to ignore.
|
|
237
|
+
* @returns {Promise<string>} SHA-256 digest.
|
|
238
|
+
*/
|
|
239
|
+
async function directoryDigest(rootPath, ignored = new Set()) {
|
|
240
|
+
const hash = createHash("sha256")
|
|
241
|
+
|
|
242
|
+
for (const relativePath of await listFiles(rootPath)) {
|
|
243
|
+
if (ignored.has(relativePath)) continue
|
|
244
|
+
const filePath = path.join(rootPath, relativePath)
|
|
245
|
+
hash.update(relativePath)
|
|
246
|
+
hash.update("\0")
|
|
247
|
+
hash.update(await fs.readFile(filePath))
|
|
248
|
+
hash.update("\0")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return hash.digest("hex")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} rootPath - Directory to walk.
|
|
256
|
+
* @returns {Promise<string[]>} Sorted files.
|
|
257
|
+
*/
|
|
258
|
+
async function listFiles(rootPath) {
|
|
259
|
+
/** @type {string[]} */
|
|
260
|
+
const files = []
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string} relativePath - Root-relative directory.
|
|
264
|
+
* @returns {Promise<void>} Resolves after walking the directory.
|
|
265
|
+
*/
|
|
266
|
+
async function walk(relativePath) {
|
|
267
|
+
const entries = await fs.readdir(path.join(rootPath, relativePath), {withFileTypes: true})
|
|
268
|
+
|
|
269
|
+
for (const entry of entries.sort((first, second) => first.name.localeCompare(second.name))) {
|
|
270
|
+
const childPath = path.join(relativePath, entry.name)
|
|
271
|
+
|
|
272
|
+
if (entry.isDirectory()) await walk(childPath)
|
|
273
|
+
else if (entry.isFile()) files.push(childPath)
|
|
274
|
+
else throw new Error(`Unsupported entry in Rollbridge runtime: ${path.join(rootPath, childPath)}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await walk("")
|
|
279
|
+
return files
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {string} rootPath - Package root.
|
|
284
|
+
* @returns {Promise<string>} Package version.
|
|
285
|
+
*/
|
|
286
|
+
async function packageVersion(rootPath) {
|
|
287
|
+
const metadata = JSON.parse(await fs.readFile(path.join(rootPath, "package.json"), "utf8"))
|
|
288
|
+
|
|
289
|
+
if (typeof metadata.version !== "string" || metadata.version.length === 0) throw new Error("Rollbridge package version is missing")
|
|
290
|
+
return metadata.version
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {import("./json.js").JsonValue} value - Parsed manifest.
|
|
295
|
+
* @returns {DaemonRuntimeIdentity} Valid identity.
|
|
296
|
+
*/
|
|
297
|
+
function parseIdentity(value) {
|
|
298
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Rollbridge daemon runtime manifest")
|
|
299
|
+
|
|
300
|
+
const identity = /** @type {Record<string, import("./json.js").JsonValue>} */ (value)
|
|
301
|
+
|
|
302
|
+
if (identity.format !== RUNTIME_FORMAT || typeof identity.digest !== "string" || !/^[a-f0-9]{64}$/.test(identity.digest) || typeof identity.path !== "string" || typeof identity.version !== "string") {
|
|
303
|
+
throw new Error("Invalid Rollbridge daemon runtime manifest")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {digest: identity.digest, format: identity.format, path: identity.path, version: identity.version}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {Error & {code?: string} | null | undefined} error - Error.
|
|
311
|
+
* @param {string} code - Error code.
|
|
312
|
+
* @returns {boolean} Whether it matches.
|
|
313
|
+
*/
|
|
314
|
+
function hasCode(error, code) {
|
|
315
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code)
|
|
316
|
+
}
|