rollbridge 0.1.16 → 0.1.17

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).
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.17",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -721,8 +721,9 @@ export default class RollbridgeDaemon {
721
721
  if (!this.statePath || this.stopping) return
722
722
 
723
723
  const statePath = this.statePath
724
- const status = this.status()
725
- const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
724
+ const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
725
+ const events = secretSafeStateValue(this.eventLog.recent())
726
+ const snapshot = {...status, events, persistedAt: new Date().toISOString()}
726
727
 
727
728
  // Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
728
729
  // clearing the file — otherwise a write started before shutdown could recreate it afterward.
@@ -851,6 +852,30 @@ function stringOrUndefined(value) {
851
852
  return value
852
853
  }
853
854
 
855
+ const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
856
+
857
+ /**
858
+ * Removes process definitions and captured output from a value before it reaches statePath.
859
+ * The live status/events APIs retain those diagnostics in memory; persistent state is only a
860
+ * secret-safe recovery aid and must not become a second process log or configuration store.
861
+ * @param {JsonValue} value - JSON value to sanitize.
862
+ * @returns {JsonValue} A secret-safe copy.
863
+ */
864
+ function secretSafeStateValue(value) {
865
+ if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
866
+ if (!value || typeof value !== "object") return value
867
+
868
+ /** @type {Record<string, JsonValue>} */
869
+ const safe = {}
870
+
871
+ for (const [key, entry] of Object.entries(value)) {
872
+ if (SECRET_BEARING_STATE_KEYS.has(key)) continue
873
+ safe[key] = secretSafeStateValue(entry)
874
+ }
875
+
876
+ return safe
877
+ }
878
+
854
879
  /**
855
880
  * @param {JsonValue} value - Value.
856
881
  * @param {string} key - Key.
@@ -20,6 +20,8 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
20
20
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
21
21
  {args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
22
22
  {args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
23
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
24
+ {args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
23
25
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
24
26
  {args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
25
27
  ]
@@ -27,7 +29,13 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
27
29
  for (const testCase of cases) {
28
30
  await t.test(testCase.message.source, async () => {
29
31
  const fixture = await createFixture()
30
- const args = testCase.args.map((arg) => arg === "CONFIG" ? fixture.configPath : arg === "RELEASE" ? fixture.root : arg)
32
+ const args = testCase.args.map((arg) => {
33
+ if (arg === "CONFIG") return fixture.configPath
34
+ if (arg === "RELEASE") return fixture.root
35
+ if (arg === "RELEASE_UNNORMALIZED") return `${fixture.root}/child/..`
36
+ if (arg === "RELEASE_MISSING") return path.join(fixture.root, "missing")
37
+ return arg
38
+ })
31
39
 
32
40
  try {
33
41
  const result = await runDaemon(args)
@@ -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)