rollbridge 0.1.14 → 0.1.16
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/compose.yml +17 -0
- package/docs/cli.md +19 -0
- package/docs/troubleshooting.md +18 -0
- package/package.json +1 -1
- package/src/cli.js +55 -17
- package/src/daemon-runtime.js +316 -0
- package/src/daemon.js +5 -2
- package/src/managed-process.js +55 -13
- package/src/process-memory.js +41 -3
- package/test/completion.test.js +1 -0
- package/test/daemon-runtime.test.js +90 -0
- package/test/managed-process.test.js +77 -0
- package/test/package-metadata.test.js +1 -1
- package/test/process-memory.test.js +21 -1
- package/test/release-runtime-retention.test.js +357 -0
- package/test/rollbridge.test.js +3 -1
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
|
@@ -62,6 +62,7 @@ and waits for control-socket deployments.
|
|
|
62
62
|
rollbridge ensure-daemon [--config <path>]
|
|
63
63
|
[--daemon-log-path <path>]
|
|
64
64
|
[--daemon-pid-path <path>]
|
|
65
|
+
[--daemon-runtime-path <path>]
|
|
65
66
|
[--daemon-start-timeout-ms <ms>]
|
|
66
67
|
```
|
|
67
68
|
|
|
@@ -69,11 +70,24 @@ Starts the daemon as a detached process **only if** the control socket is not
|
|
|
69
70
|
already accepting commands, waits until it responds, then prints the daemon
|
|
70
71
|
status JSON. Idempotent — safe to call before every deploy.
|
|
71
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
|
+
|
|
72
81
|
- `--daemon-log-path <path>` — file the detached daemon's stdout/stderr is
|
|
73
82
|
appended to. Default: `/tmp/rollbridge-<application>.log`. See
|
|
74
83
|
[`logging.md`](logging.md) for the log format and rotation guidance.
|
|
75
84
|
- `--daemon-pid-path <path>` — file the detached daemon's PID is written to.
|
|
76
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.
|
|
77
91
|
- `--daemon-start-timeout-ms <ms>` — how long to wait for the daemon to accept
|
|
78
92
|
control commands before failing. Default: `10000`.
|
|
79
93
|
|
|
@@ -87,6 +101,7 @@ rollbridge deploy --release-path <path>
|
|
|
87
101
|
[--ensure-daemon]
|
|
88
102
|
[--daemon-log-path <path>]
|
|
89
103
|
[--daemon-pid-path <path>]
|
|
104
|
+
[--daemon-runtime-path <path>]
|
|
90
105
|
[--daemon-start-timeout-ms <ms>]
|
|
91
106
|
```
|
|
92
107
|
|
|
@@ -155,6 +170,10 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
|
|
|
155
170
|
`lastMemoryRestartAt`, and `children` (the process tree: each group member's
|
|
156
171
|
`pid`, `command`, and `rssBytes`).
|
|
157
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
|
+
|
|
158
177
|
When [`statePath`](config.md#statepath) is configured, status also includes an
|
|
159
178
|
`orphans` array: managed processes from a **previous** daemon that are still
|
|
160
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,7 +39,8 @@ 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
45
|
await daemon.start({exposeControl: !bootstrap})
|
|
43
46
|
|
|
@@ -71,6 +74,7 @@ export async function runCli(argv) {
|
|
|
71
74
|
.option("--ensure-daemon", "Start the Rollbridge daemon if it is not already running")
|
|
72
75
|
.option("--daemon-log-path <path>", "Log path used when --ensure-daemon starts the daemon")
|
|
73
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")
|
|
74
78
|
.option("--daemon-start-timeout-ms <ms>", "How long to wait for an ensured daemon to accept control commands")
|
|
75
79
|
.action(async (options) => {
|
|
76
80
|
const configPath = await resolveConfigPath(options.config)
|
|
@@ -78,11 +82,11 @@ export async function runCli(argv) {
|
|
|
78
82
|
|
|
79
83
|
if (options.ensureDaemon) {
|
|
80
84
|
await ensureDaemonRunning({
|
|
81
|
-
argv,
|
|
82
85
|
config,
|
|
83
86
|
configPath,
|
|
84
87
|
logPath: options.daemonLogPath,
|
|
85
88
|
pidPath: options.daemonPidPath,
|
|
89
|
+
runtimePath: options.daemonRuntimePath,
|
|
86
90
|
timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
|
|
87
91
|
})
|
|
88
92
|
}
|
|
@@ -125,16 +129,17 @@ export async function runCli(argv) {
|
|
|
125
129
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
126
130
|
.option("--daemon-log-path <path>", "Daemon log path")
|
|
127
131
|
.option("--daemon-pid-path <path>", "Daemon PID file path")
|
|
132
|
+
.option("--daemon-runtime-path <path>", "Directory for durable daemon runtime snapshots")
|
|
128
133
|
.option("--daemon-start-timeout-ms <ms>", "How long to wait for the daemon to accept control commands")
|
|
129
134
|
.action(async (options) => {
|
|
130
135
|
const configPath = await resolveConfigPath(options.config)
|
|
131
136
|
const config = await loadConfig(configPath)
|
|
132
137
|
const response = await ensureDaemonRunning({
|
|
133
|
-
argv,
|
|
134
138
|
config,
|
|
135
139
|
configPath,
|
|
136
140
|
logPath: options.daemonLogPath,
|
|
137
141
|
pidPath: options.daemonPidPath,
|
|
142
|
+
runtimePath: options.daemonRuntimePath,
|
|
138
143
|
timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
|
|
139
144
|
})
|
|
140
145
|
|
|
@@ -758,27 +763,34 @@ async function validateDaemonBootstrapOptions(options) {
|
|
|
758
763
|
/**
|
|
759
764
|
* Starts a daemon when needed and waits until it accepts status commands.
|
|
760
765
|
* @param {object} args - Options.
|
|
761
|
-
* @param {string[]} args.argv - Original CLI argv.
|
|
762
766
|
* @param {import("./config.js").RollbridgeConfig} args.config - Loaded config.
|
|
763
767
|
* @param {string} args.configPath - Config path.
|
|
764
768
|
* @param {string | undefined} args.logPath - Optional daemon log path.
|
|
765
769
|
* @param {string | undefined} args.pidPath - Optional daemon PID path.
|
|
770
|
+
* @param {string | undefined} args.runtimePath - Optional durable runtime directory.
|
|
766
771
|
* @param {number} args.timeoutMs - Startup timeout.
|
|
767
772
|
* @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
|
|
768
773
|
*/
|
|
769
|
-
async function ensureDaemonRunning({
|
|
774
|
+
async function ensureDaemonRunning({config, configPath, logPath, pidPath, runtimePath, timeoutMs}) {
|
|
775
|
+
const runtime = await prepareDaemonRuntime(runtimePath || defaultDaemonRuntimePath(config))
|
|
770
776
|
const existingStatus = await daemonStatus(config)
|
|
771
777
|
|
|
772
|
-
if (existingStatus)
|
|
778
|
+
if (existingStatus) {
|
|
779
|
+
assertCompatibleDaemonRuntime(existingStatus, runtime)
|
|
780
|
+
return existingStatus
|
|
781
|
+
}
|
|
773
782
|
|
|
774
783
|
await startDaemonProcess({
|
|
775
|
-
argv,
|
|
776
784
|
configPath,
|
|
777
785
|
logPath: logPath || defaultDaemonLogPath(config),
|
|
778
|
-
pidPath: pidPath || defaultDaemonPidPath(config)
|
|
786
|
+
pidPath: pidPath || defaultDaemonPidPath(config),
|
|
787
|
+
runtime
|
|
779
788
|
})
|
|
780
789
|
|
|
781
|
-
|
|
790
|
+
const startedStatus = await waitForDaemonStatus(config, timeoutMs)
|
|
791
|
+
|
|
792
|
+
assertCompatibleDaemonRuntime(startedStatus, runtime)
|
|
793
|
+
return startedStatus
|
|
782
794
|
}
|
|
783
795
|
|
|
784
796
|
/**
|
|
@@ -805,17 +817,13 @@ async function daemonStatus(config) {
|
|
|
805
817
|
/**
|
|
806
818
|
* Starts the foreground daemon command as a detached child.
|
|
807
819
|
* @param {object} args - Options.
|
|
808
|
-
* @param {string[]} args.argv - Original CLI argv.
|
|
809
820
|
* @param {string} args.configPath - Config path.
|
|
810
821
|
* @param {string} args.logPath - Log file path.
|
|
811
822
|
* @param {string} args.pidPath - PID file path.
|
|
823
|
+
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} args.runtime - Prepared runtime.
|
|
812
824
|
* @returns {Promise<void>} Resolves after the child has been spawned.
|
|
813
825
|
*/
|
|
814
|
-
async function startDaemonProcess({
|
|
815
|
-
const binPath = argv[1] || process.argv[1]
|
|
816
|
-
|
|
817
|
-
if (!binPath) throw new Error("Unable to determine Rollbridge CLI path for daemon startup")
|
|
818
|
-
|
|
826
|
+
async function startDaemonProcess({configPath, logPath, pidPath, runtime}) {
|
|
819
827
|
await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
|
|
820
828
|
await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
|
|
821
829
|
|
|
@@ -823,9 +831,9 @@ async function startDaemonProcess({argv, configPath, logPath, pidPath}) {
|
|
|
823
831
|
const stderrFd = fs.openSync(logPath, "a")
|
|
824
832
|
|
|
825
833
|
try {
|
|
826
|
-
const child = spawn(process.execPath, [
|
|
834
|
+
const child = spawn(process.execPath, [path.join(runtime.path, "bin", "rollbridge"), "daemon", "--config", configPath], {
|
|
827
835
|
detached: true,
|
|
828
|
-
env: process.env,
|
|
836
|
+
env: {...process.env, ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST: path.join(runtime.path, "runtime.json")},
|
|
829
837
|
stdio: ["ignore", stdoutFd, stderrFd]
|
|
830
838
|
})
|
|
831
839
|
|
|
@@ -883,6 +891,36 @@ function defaultDaemonPidPath(config) {
|
|
|
883
891
|
return `/tmp/rollbridge-${config.application}.pid`
|
|
884
892
|
}
|
|
885
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
|
+
|
|
886
924
|
/**
|
|
887
925
|
* @param {string | undefined} value - Raw timeout value.
|
|
888
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
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -18,7 +18,7 @@ const STATE_PERSIST_INTERVAL_MS = 5000
|
|
|
18
18
|
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
19
19
|
* @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
|
|
20
20
|
* @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
|
|
21
|
-
* @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
21
|
+
* @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
export default class RollbridgeDaemon {
|
|
@@ -27,10 +27,12 @@ export default class RollbridgeDaemon {
|
|
|
27
27
|
* @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
|
|
28
28
|
* @param {string} [args.configPath] - Config file path to reload before deploys.
|
|
29
29
|
* @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
|
|
30
|
+
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} [args.runtime] - Immutable daemon runtime identity.
|
|
30
31
|
*/
|
|
31
|
-
constructor({config, configPath, logger}) {
|
|
32
|
+
constructor({config, configPath, logger, runtime}) {
|
|
32
33
|
this.config = config
|
|
33
34
|
this.configPath = configPath
|
|
35
|
+
this.runtime = runtime
|
|
34
36
|
this.eventLog = new EventLog(EVENT_HISTORY_LIMIT)
|
|
35
37
|
|
|
36
38
|
const baseLogger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
|
|
@@ -818,6 +820,7 @@ export default class RollbridgeDaemon {
|
|
|
818
820
|
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
819
821
|
application: this.config.application,
|
|
820
822
|
control: {...this.config.control},
|
|
823
|
+
daemonRuntime: this.runtime ? {...this.runtime} : undefined,
|
|
821
824
|
orphans: [...this.orphans],
|
|
822
825
|
proxy: {
|
|
823
826
|
host: this.config.proxy.host,
|