rollbridge 0.1.16 → 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
@@ -162,10 +162,13 @@ owns cleaning up on-disk release directories.
162
162
  releaseRetention: {keep: 5, maxAgeMs: 86400000}
163
163
  ```
164
164
 
165
- Set `statePath` to have the daemon persist its state to a file (active/draining
166
- releases, process pids, counters, recent events). On the next startup it reads
167
- any leftover file and reports managed processes still alive from a daemon that
168
- didn't shut down cleanly advisory orphan detection. After a crash, run
165
+ Set `statePath` to have the daemon persist secret-safe recovery state to a file
166
+ (active/draining releases, process pids, counters, sanitized recent events).
167
+ Commands, environment mappings, child command lines, and captured process output
168
+ are deliberately excluded; those diagnostics remain available through the live
169
+ status/log/event APIs. On the next startup Rollbridge reads any leftover file and
170
+ reports managed processes still alive from a daemon that didn't shut down cleanly
171
+ — advisory orphan detection. After a crash, run
169
172
  `rollbridge recover` to list those leftovers and `rollbridge recover --force` to
170
173
  stop them before restarting the daemon. A clean `shutdown` removes the file. See
171
174
  [`docs/config.md`](docs/config.md#statepath).
@@ -405,7 +408,8 @@ the foreground (for example from a boot-time service manager):
405
408
  ```bash
406
409
  rollbridge daemon --config /srv/ticket-server/rollbridge.js \
407
410
  --release-path /srv/ticket-server/releases/20260813090000/ticket-server \
408
- --release-id 20260813090000 --revision abc123
411
+ --release-id 20260813090000 --revision abc123 \
412
+ --boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
409
413
  ```
410
414
 
411
415
  The four bootstrap inputs are all-or-nothing and use absolute config/release
@@ -416,6 +420,14 @@ persisted processes from a previous daemon are reported as orphans and are never
416
420
  recovered or killed implicitly; their live PID records remain in `statePath` for
417
421
  explicit recovery.
418
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
+
419
431
  Start the daemon only when it is not already running:
420
432
 
421
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/docs/config.md CHANGED
@@ -90,10 +90,13 @@ release records; the deploy tool still owns on-disk release directories.
90
90
 
91
91
  ## `statePath`
92
92
 
93
- When set, the daemon persists a state snapshot — the active and draining
94
- releases, each managed process's metadata (including pid), restart counters, and
95
- recent events — to this file (atomically, on changes and every few seconds). On a
96
- clean `shutdown` the file is removed.
93
+ When set, the daemon persists a secret-safe state snapshot — the active and
94
+ draining releases, each managed process's recovery metadata (including pid),
95
+ restart counters, and recent structured events — to this file (atomically, on
96
+ changes and every few seconds). Process commands, working directories,
97
+ environment mappings, child command lines, and retained stdout/stderr are never
98
+ persisted. They remain available from the live `status`, `logs`, and `events`
99
+ APIs while the daemon is running. On a clean `shutdown` the file is removed.
97
100
 
98
101
  On the **next startup**, the daemon reads any leftover file and reports managed
99
102
  processes whose pids are still alive — likely orphans from a daemon that crashed
package/docs/logging.md CHANGED
@@ -36,6 +36,12 @@ events. It is distinct from the two in-memory views:
36
36
  - `rollbridge events` — the recent structured daemon event history (the most
37
37
  recent 1000 events), the same events written to the log file.
38
38
 
39
+ When `statePath` is configured, its recovery snapshot is intentionally not a
40
+ log archive: process commands, environment mappings, child command lines, and
41
+ captured stdout/stderr are excluded. Use the live APIs above or the configured
42
+ daemon log for those diagnostics, and protect that log according to the
43
+ sensitivity of application output.
44
+
39
45
  Both are cleared when the daemon restarts; the log file persists.
40
46
 
41
47
  ## Rotation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.16",
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
@@ -721,8 +724,9 @@ export default class RollbridgeDaemon {
721
724
  if (!this.statePath || this.stopping) return
722
725
 
723
726
  const statePath = this.statePath
724
- const status = this.status()
725
- const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
727
+ const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
728
+ const events = secretSafeStateValue(this.eventLog.recent())
729
+ const snapshot = {...status, events, persistedAt: new Date().toISOString()}
726
730
 
727
731
  // Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
728
732
  // clearing the file — otherwise a write started before shutdown could recreate it afterward.
@@ -819,6 +823,7 @@ export default class RollbridgeDaemon {
819
823
  return {
820
824
  activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
821
825
  application: this.config.application,
826
+ bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
822
827
  control: {...this.config.control},
823
828
  daemonRuntime: this.runtime ? {...this.runtime} : undefined,
824
829
  orphans: [...this.orphans],
@@ -851,6 +856,30 @@ function stringOrUndefined(value) {
851
856
  return value
852
857
  }
853
858
 
859
+ const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
860
+
861
+ /**
862
+ * Removes process definitions and captured output from a value before it reaches statePath.
863
+ * The live status/events APIs retain those diagnostics in memory; persistent state is only a
864
+ * secret-safe recovery aid and must not become a second process log or configuration store.
865
+ * @param {JsonValue} value - JSON value to sanitize.
866
+ * @returns {JsonValue} A secret-safe copy.
867
+ */
868
+ function secretSafeStateValue(value) {
869
+ if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
870
+ if (!value || typeof value !== "object") return value
871
+
872
+ /** @type {Record<string, JsonValue>} */
873
+ const safe = {}
874
+
875
+ for (const [key, entry] of Object.entries(value)) {
876
+ if (SECRET_BEARING_STATE_KEYS.has(key)) continue
877
+ safe[key] = secretSafeStateValue(entry)
878
+ }
879
+
880
+ return safe
881
+ }
882
+
854
883
  /**
855
884
  * @param {JsonValue} value - Value.
856
885
  * @param {string} key - Key.
@@ -14,20 +14,34 @@ 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 = [
20
22
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
21
23
  {args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
22
24
  {args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
25
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
26
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
23
27
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
24
- {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/}
25
33
  ]
26
34
 
27
35
  for (const testCase of cases) {
28
36
  await t.test(testCase.message.source, async () => {
29
37
  const fixture = await createFixture()
30
- const args = testCase.args.map((arg) => arg === "CONFIG" ? fixture.configPath : arg === "RELEASE" ? fixture.root : arg)
38
+ const args = testCase.args.map((arg) => {
39
+ if (arg === "CONFIG") return fixture.configPath
40
+ if (arg === "RELEASE") return fixture.root
41
+ if (arg === "RELEASE_UNNORMALIZED") return `${fixture.root}/child/..`
42
+ if (arg === "RELEASE_MISSING") return path.join(fixture.root, "missing")
43
+ return arg
44
+ })
31
45
 
32
46
  try {
33
47
  const result = await runDaemon(args)
@@ -35,6 +49,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
35
49
  assert.notEqual(result.code, 0)
36
50
  assert.match(result.stderr, testCase.message)
37
51
  await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
52
+ await assert.rejects(() => fs.stat(fixture.startedPath), {code: "ENOENT"})
38
53
  } finally {
39
54
  await fs.rm(fixture.root, {force: true, recursive: true})
40
55
  }
@@ -44,7 +59,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
44
59
 
45
60
  test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
46
61
  const fixture = await createFixture()
47
- const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
62
+ const child = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "release-42", revision: "abc123"})
48
63
 
49
64
  try {
50
65
  await waitForLog(child, "control socket listening")
@@ -53,6 +68,12 @@ test("daemon bootstrap activates the exact release through the foreground daemon
53
68
 
54
69
  assert.equal(activeRelease.releasePath, fixture.root)
55
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
+ })
56
77
  assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
57
78
  assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
58
79
 
@@ -101,6 +122,7 @@ test("plain daemon startup remains listener-only with no active release", async
101
122
 
102
123
  assert.equal(status.activeReleaseId, null)
103
124
  assert.deepEqual(status.releases, [])
125
+ assert.equal(status.bootstrap, undefined)
104
126
 
105
127
  child.kill("SIGTERM")
106
128
  assert.equal((await once(child, "exit"))[0], 0)
@@ -110,6 +132,47 @@ test("plain daemon startup remains listener-only with no active release", async
110
132
  }
111
133
  })
112
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
+
113
176
  test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
114
177
  const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
115
178
  const started = waitForFile(fixture.startedPath)
@@ -344,11 +407,15 @@ async function waitForFile(filePath) {
344
407
 
345
408
  /**
346
409
  * @param {{configPath: string, root: string}} fixture - Fixture paths.
347
- * @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
410
+ * @param {{attestation?: string, releaseId: string, revision: string}} release - Bootstrap metadata.
348
411
  * @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
349
412
  */
350
413
  function spawnDaemon(fixture, release) {
351
- 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"]})
352
419
  }
353
420
 
354
421
  /**
@@ -356,7 +423,15 @@ function spawnDaemon(fixture, release) {
356
423
  * @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
357
424
  */
358
425
  async function runDaemon(args) {
359
- 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"]})
360
435
  let output = ""
361
436
  let stderr = ""
362
437
 
@@ -544,6 +544,10 @@ test("does not auto-restart when the restart policy is disabled (maxRestarts: 0)
544
544
 
545
545
  test("stops auto-restarting once maxRestarts within the window is reached", async () => {
546
546
  const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 0, maxRestarts: 2, windowMs: 60000})
547
+ /** @type {{data: Record<string, import("../src/json.js").JsonValue>, message: string}[]} */
548
+ const events = []
549
+
550
+ managed.logger = (message, data = {}) => { events.push({data, message}) }
547
551
 
548
552
  try {
549
553
  await managed.start()
@@ -554,6 +558,11 @@ test("stops auto-restarting once maxRestarts within the window is reached", asyn
554
558
 
555
559
  assert.equal(managed.status().restarts, 2)
556
560
  assert.equal(managed.status().state, "failed")
561
+ assert.deepEqual(events.find((event) => event.message === "restart limit reached")?.data, {
562
+ id: "crasher",
563
+ maxRestarts: 2,
564
+ windowMs: 60000
565
+ })
557
566
  } finally {
558
567
  await managed.stop()
559
568
  }
@@ -8,11 +8,11 @@ import net from "node:net"
8
8
  import os from "node:os"
9
9
  import path from "node:path"
10
10
  import test from "node:test"
11
- import {fileURLToPath} from "node:url"
11
+ import {fileURLToPath, pathToFileURL} from "node:url"
12
12
  import RollbridgeDaemon from "../src/daemon.js"
13
13
  import {normalizeConfig} from "../src/config.js"
14
14
  import {sendControlCommand} from "../src/control-client.js"
15
- import {readState, writeState} from "../src/state-store.js"
15
+ import {liveProcesses, readState, writeState} from "../src/state-store.js"
16
16
  import {runCli} from "../src/cli.js"
17
17
 
18
18
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
@@ -635,6 +635,37 @@ test("persists daemon state to statePath and removes it on a clean shutdown", as
635
635
  assert.equal(stateAfterShutdown, undefined, "state file removed on clean shutdown")
636
636
  })
637
637
 
638
+ test("persisted daemon state excludes process commands, environment values, and output", async () => {
639
+ const secret = "state-secret-value"
640
+ const fixture = await createFixture({persistState: true})
641
+ const web = fixture.config.processes.find((processConfig) => processConfig.id === "web")
642
+
643
+ assert.ok(web)
644
+ web.env.ROLLBRIDGE_TEST_SECRET = secret
645
+ web.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`console.log(process.env.ROLLBRIDGE_TEST_SECRET); import(${JSON.stringify(pathToFileURL(dummyAppPath).href)})`)}`
646
+
647
+ const daemon = await startDaemon(fixture.config)
648
+
649
+ try {
650
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
651
+ await waitFor(() => Boolean(daemon.activeRelease?.getProcess("web")?.status().logs.some((entry) => entry.line === secret)))
652
+
653
+ daemon.persistState()
654
+ await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
655
+
656
+ const persisted = await fs.readFile(fixture.statePath, "utf8")
657
+
658
+ assert.doesNotMatch(persisted, /state-secret-value/)
659
+ assert.doesNotMatch(persisted, /ROLLBRIDGE_TEST_SECRET/)
660
+ assert.doesNotMatch(persisted, /"command"/)
661
+ assert.doesNotMatch(persisted, /"logs"/)
662
+ assert.deepEqual(liveProcesses(JSON.parse(persisted), () => true).map(({id, releaseId}) => ({id, releaseId})), [{id: "web", releaseId: "v1"}])
663
+ } finally {
664
+ await daemon.shutdown()
665
+ await fs.rm(fixture.root, {force: true, recursive: true})
666
+ }
667
+ })
668
+
638
669
  test("a clean shutdown clears the state file even when a persist write is in flight", async () => {
639
670
  const fixture = await createFixture({persistState: true})
640
671
  const daemon = await startDaemon(fixture.config)
@@ -1130,6 +1161,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
1130
1161
  const proxy = /** @type {{port: number}} */ (status.proxy)
1131
1162
 
1132
1163
  assert.equal(status.activeReleaseId, "ensured-v1")
1164
+ assert.equal(status.bootstrap, undefined)
1133
1165
  assert.match(await fs.readFile(pidPath, "utf8"), /\d+/)
1134
1166
  assert.equal(await fetchTextFromPort(proxy.port, "/release"), "ensured-v1")
1135
1167
  } finally {