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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
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 daemon = new RollbridgeDaemon({config, configPath})
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({argv, config, configPath, logPath, pidPath, timeoutMs}) {
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) return 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
- return await waitForDaemonStatus(config, timeoutMs)
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({argv, configPath, logPath, pidPath}) {
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, [binPath, "daemon", "--config", configPath], {
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,