rollbridge 0.1.17 → 0.1.18

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 CHANGED
@@ -408,7 +408,8 @@ the foreground (for example from a boot-time service manager):
408
408
  ```bash
409
409
  rollbridge daemon --config /srv/ticket-server/rollbridge.js \
410
410
  --release-path /srv/ticket-server/releases/20260813090000/ticket-server \
411
- --release-id 20260813090000 --revision abc123
411
+ --release-id 20260813090000 --revision abc123 \
412
+ --boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
412
413
  ```
413
414
 
414
415
  The four bootstrap inputs are all-or-nothing and use absolute config/release
@@ -419,6 +420,14 @@ persisted processes from a previous daemon are reported as orphans and are never
419
420
  recovered or killed implicitly; their live PID records remain in `statePath` for
420
421
  explicit recovery.
421
422
 
423
+ External supervisors may add `--boot-attestation` with exactly `sha256:` plus
424
+ 64 lowercase hexadecimal characters. After successful activation, `rollbridge
425
+ status` echoes the opaque, non-secret value under `bootstrap.attestation`
426
+ alongside the exact release id/path/revision. Listener-only and detached ensured
427
+ daemons omit `bootstrap`, so a supervisor can distinguish a newly accepted
428
+ foreground owner from a stale daemon without Rollbridge interpreting the token.
429
+ See [`docs/cli.md`](docs/cli.md#daemon) for the complete contract.
430
+
422
431
  Start the daemon only when it is not already running:
423
432
 
424
433
  ```bash
package/docs/cli.md CHANGED
@@ -24,7 +24,8 @@ process-policy details.
24
24
 
25
25
  ```
26
26
  rollbridge daemon [--config <path>]
27
- [--release-path <path> --release-id <id> --revision <sha>]
27
+ [--release-path <path> --release-id <id> --revision <sha>
28
+ [--boot-attestation <sha256:digest>]]
28
29
  ```
29
30
 
30
31
  Runs the supervisor in the foreground: binds the stable proxy port and the
@@ -53,6 +54,13 @@ release. `statePath` entries from a previous daemon remain advisory orphans:
53
54
  bootstrap never runs recovery and never signals those processes, and retains
54
55
  their live PID records in `statePath` for explicit recovery.
55
56
 
57
+ `--boot-attestation` is an optional, non-secret opaque ownership token for an
58
+ external supervisor. Its canonical format is exactly `sha256:` followed by 64
59
+ lowercase hexadecimal characters. It is accepted only with the complete
60
+ known-release bootstrap tuple above and is never accepted by `ensure-daemon`.
61
+ After successful activation, `status` echoes it unchanged in the bootstrap
62
+ identity. Rollbridge does not calculate or interpret the digest.
63
+
56
64
  With no release options, daemon behavior is unchanged: it starts listener-only
57
65
  and waits for control-socket deployments.
58
66
 
@@ -174,6 +182,24 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
174
182
  its runtime `format`, package `version`, content `digest`, and absolute `path`.
175
183
  `ensure-daemon` uses this attestation before reusing a responsive daemon.
176
184
 
185
+ A foreground known-release daemon also reports the exact CLI bootstrap identity:
186
+
187
+ ```json
188
+ {
189
+ "bootstrap": {
190
+ "releaseId": "20260813090000",
191
+ "releasePath": "/srv/app/releases/20260813090000/app",
192
+ "revision": "abc123",
193
+ "attestation": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
194
+ }
195
+ }
196
+ ```
197
+
198
+ `attestation` is omitted when the optional argument was not supplied. Ordinary
199
+ listener-only daemons and detached daemons created by `ensure-daemon` omit the
200
+ entire `bootstrap` object. External supervisors can therefore distinguish two
201
+ otherwise identical foreground boots by comparing the opaque attestation.
202
+
177
203
  When [`statePath`](config.md#statepath) is configured, status also includes an
178
204
  `orphans` array: managed processes from a **previous** daemon that are still
179
205
  alive (`id`, `pid`, `releaseId`) — for example after the daemon restarted but its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
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
@@ -35,12 +35,13 @@ export async function runCli(argv) {
35
35
  .option("--release-path <path>", "Bootstrap release path (requires --config, --release-id, and --revision)")
36
36
  .option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
37
37
  .option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
38
+ .option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
38
39
  .action(async (options) => {
39
40
  const bootstrap = await validateDaemonBootstrapOptions(options)
40
41
  const configPath = await resolveConfigPath(options.config)
41
42
  const config = await loadConfig(configPath)
42
43
  const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
43
- const daemon = new RollbridgeDaemon({config, configPath, runtime})
44
+ const daemon = new RollbridgeDaemon({bootstrap, config, configPath, runtime})
44
45
 
45
46
  await daemon.start({exposeControl: !bootstrap})
46
47
 
@@ -720,13 +721,17 @@ async function validateConfigFile(configPath) {
720
721
  /**
721
722
  * Validates the daemon's optional all-or-nothing bootstrap release interface before
722
723
  * config loading or listener startup.
723
- * @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
724
- * @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
724
+ * @param {{bootAttestation?: string, config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
725
+ * @returns {Promise<{attestation?: string, releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
725
726
  */
726
727
  async function validateDaemonBootstrapOptions(options) {
727
728
  const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
728
729
  const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
729
730
 
731
+ if (options.bootAttestation !== undefined && !bootstrapRequested) {
732
+ throw new Error("Daemon --boot-attestation is accepted only with --config, --release-path, --release-id, and --revision.")
733
+ }
734
+
730
735
  if (!bootstrapRequested) return undefined
731
736
 
732
737
  if (!options.config || bootstrapValues.some((value) => value === undefined)) {
@@ -744,6 +749,7 @@ async function validateDaemonBootstrapOptions(options) {
744
749
 
745
750
  if (!safeIdentifier.test(releaseId)) throw new Error("Daemon bootstrap --release-id must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
746
751
  if (!safeIdentifier.test(revision)) throw new Error("Daemon bootstrap --revision must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
752
+ if (options.bootAttestation !== undefined && !/^sha256:[a-f0-9]{64}$/.test(options.bootAttestation)) throw new Error("Daemon bootstrap --boot-attestation must use the canonical sha256:<64 lowercase hex> format.")
747
753
 
748
754
  let releaseStat
749
755
 
@@ -757,7 +763,7 @@ async function validateDaemonBootstrapOptions(options) {
757
763
 
758
764
  if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
759
765
 
760
- return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
766
+ return {attestation: options.bootAttestation, releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
761
767
  }
762
768
 
763
769
  /**
package/src/daemon.js CHANGED
@@ -17,19 +17,22 @@ const STATE_PERSIST_INTERVAL_MS = 5000
17
17
  /**
18
18
  * @typedef {import("./json.js").JsonValue} JsonValue
19
19
  * @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
20
+ * @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
20
21
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
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
+ * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, 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
23
  */
23
24
 
24
25
  export default class RollbridgeDaemon {
25
26
  /**
26
27
  * @param {object} args - Options.
28
+ * @param {BootstrapIdentity} [args.bootstrap] - Immutable known-release foreground bootstrap identity.
27
29
  * @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
28
30
  * @param {string} [args.configPath] - Config file path to reload before deploys.
29
31
  * @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
30
32
  * @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} [args.runtime] - Immutable daemon runtime identity.
31
33
  */
32
- constructor({config, configPath, logger, runtime}) {
34
+ constructor({bootstrap, config, configPath, logger, runtime}) {
35
+ this.bootstrap = bootstrap ? {...bootstrap} : undefined
33
36
  this.config = config
34
37
  this.configPath = configPath
35
38
  this.runtime = runtime
@@ -820,6 +823,7 @@ export default class RollbridgeDaemon {
820
823
  return {
821
824
  activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
822
825
  application: this.config.application,
826
+ bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
823
827
  control: {...this.config.control},
824
828
  daemonRuntime: this.runtime ? {...this.runtime} : undefined,
825
829
  orphans: [...this.orphans],
@@ -14,6 +14,8 @@ import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state
14
14
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
15
15
  const binPath = path.join(currentDir, "..", "bin", "rollbridge")
16
16
  const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
17
+ const firstAttestation = `sha256:${"a".repeat(64)}`
18
+ const secondAttestation = `sha256:${"b".repeat(64)}`
17
19
 
18
20
  test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
19
21
  const cases = [
@@ -23,7 +25,11 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
23
25
  {args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
24
26
  {args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
25
27
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
26
- {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
28
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/},
29
+ {args: ["--config", "CONFIG", "--boot-attestation", firstAttestation], message: /accepted only with/},
30
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha256:${"A".repeat(64)}`], message: /--boot-attestation/},
31
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha512:${"a".repeat(64)}`], message: /--boot-attestation/},
32
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha256:${"a".repeat(63)}`], message: /--boot-attestation/}
27
33
  ]
28
34
 
29
35
  for (const testCase of cases) {
@@ -43,6 +49,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
43
49
  assert.notEqual(result.code, 0)
44
50
  assert.match(result.stderr, testCase.message)
45
51
  await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
52
+ await assert.rejects(() => fs.stat(fixture.startedPath), {code: "ENOENT"})
46
53
  } finally {
47
54
  await fs.rm(fixture.root, {force: true, recursive: true})
48
55
  }
@@ -52,7 +59,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
52
59
 
53
60
  test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
54
61
  const fixture = await createFixture()
55
- const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
62
+ const child = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "release-42", revision: "abc123"})
56
63
 
57
64
  try {
58
65
  await waitForLog(child, "control socket listening")
@@ -61,6 +68,12 @@ test("daemon bootstrap activates the exact release through the foreground daemon
61
68
 
62
69
  assert.equal(activeRelease.releasePath, fixture.root)
63
70
  assert.equal(activeRelease.revision, "abc123")
71
+ assert.deepEqual(status.bootstrap, {
72
+ attestation: firstAttestation,
73
+ releaseId: "release-42",
74
+ releasePath: fixture.root,
75
+ revision: "abc123"
76
+ })
64
77
  assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
65
78
  assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
66
79
 
@@ -109,6 +122,7 @@ test("plain daemon startup remains listener-only with no active release", async
109
122
 
110
123
  assert.equal(status.activeReleaseId, null)
111
124
  assert.deepEqual(status.releases, [])
125
+ assert.equal(status.bootstrap, undefined)
112
126
 
113
127
  child.kill("SIGTERM")
114
128
  assert.equal((await once(child, "exit"))[0], 0)
@@ -118,6 +132,47 @@ test("plain daemon startup remains listener-only with no active release", async
118
132
  }
119
133
  })
120
134
 
135
+ test("ensure-daemon rejects boot attestation instead of inheriting foreground identity", async () => {
136
+ const fixture = await createFixture()
137
+
138
+ try {
139
+ const result = await runRollbridge(["ensure-daemon", "--config", fixture.configPath, "--boot-attestation", firstAttestation])
140
+
141
+ assert.notEqual(result.code, 0)
142
+ assert.match(result.stderr, /unknown option '--boot-attestation'/)
143
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
144
+ await assert.rejects(() => fs.stat(fixture.startedPath), {code: "ENOENT"})
145
+ } finally {
146
+ await fs.rm(fixture.root, {force: true, recursive: true})
147
+ }
148
+ })
149
+
150
+ test("otherwise identical foreground boots remain distinguishable by attestation", async () => {
151
+ const fixture = await createFixture()
152
+
153
+ try {
154
+ const attestations = []
155
+
156
+ for (const attestation of [firstAttestation, secondAttestation]) {
157
+ const child = spawnDaemon(fixture, {attestation, releaseId: "same-release", revision: "same-revision"})
158
+
159
+ await waitForLog(child, "control socket listening")
160
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
161
+
162
+ assert.equal(status.activeReleaseId, "same-release")
163
+ assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
164
+ attestations.push(status.bootstrap.attestation)
165
+
166
+ child.kill("SIGTERM")
167
+ assert.equal((await once(child, "exit"))[0], 0)
168
+ }
169
+
170
+ assert.deepEqual(attestations, [firstAttestation, secondAttestation])
171
+ } finally {
172
+ await fs.rm(fixture.root, {force: true, recursive: true})
173
+ }
174
+ })
175
+
121
176
  test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
122
177
  const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
123
178
  const started = waitForFile(fixture.startedPath)
@@ -352,11 +407,15 @@ async function waitForFile(filePath) {
352
407
 
353
408
  /**
354
409
  * @param {{configPath: string, root: string}} fixture - Fixture paths.
355
- * @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
410
+ * @param {{attestation?: string, releaseId: string, revision: string}} release - Bootstrap metadata.
356
411
  * @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
357
412
  */
358
413
  function spawnDaemon(fixture, release) {
359
- return spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath, "--release-path", fixture.root, "--release-id", release.releaseId, "--revision", release.revision], {stdio: ["pipe", "pipe", "pipe"]})
414
+ const args = [binPath, "daemon", "--config", fixture.configPath, "--release-path", fixture.root, "--release-id", release.releaseId, "--revision", release.revision]
415
+
416
+ if (release.attestation) args.push("--boot-attestation", release.attestation)
417
+
418
+ return spawn(process.execPath, args, {stdio: ["pipe", "pipe", "pipe"]})
360
419
  }
361
420
 
362
421
  /**
@@ -364,7 +423,15 @@ function spawnDaemon(fixture, release) {
364
423
  * @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
365
424
  */
366
425
  async function runDaemon(args) {
367
- const child = spawn(process.execPath, [binPath, "daemon", ...args], {stdio: ["ignore", "pipe", "pipe"]})
426
+ return await runRollbridge(["daemon", ...args])
427
+ }
428
+
429
+ /**
430
+ * @param {string[]} args - Rollbridge command and arguments.
431
+ * @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
432
+ */
433
+ async function runRollbridge(args) {
434
+ const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
368
435
  let output = ""
369
436
  let stderr = ""
370
437
 
@@ -1161,6 +1161,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
1161
1161
  const proxy = /** @type {{port: number}} */ (status.proxy)
1162
1162
 
1163
1163
  assert.equal(status.activeReleaseId, "ensured-v1")
1164
+ assert.equal(status.bootstrap, undefined)
1164
1165
  assert.match(await fs.readFile(pidPath, "utf8"), /\d+/)
1165
1166
  assert.equal(await fetchTextFromPort(proxy.port, "/release"), "ensured-v1")
1166
1167
  } finally {