rollbridge 0.1.13 → 0.1.15

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.
Files changed (33) hide show
  1. package/README.md +6 -5
  2. package/compose.yml +17 -0
  3. package/docs/cli.md +28 -8
  4. package/docs/troubleshooting.md +18 -0
  5. package/package.json +1 -1
  6. package/src/cli.js +57 -18
  7. package/src/daemon-runtime.js +316 -0
  8. package/src/daemon.js +25 -4
  9. package/test/completion.test.js +1 -0
  10. package/test/daemon-bootstrap.test.js +40 -7
  11. package/test/daemon-runtime.test.js +90 -0
  12. package/test/fixtures/dummy-app.js +1 -1
  13. package/test/package-metadata.test.js +39 -0
  14. package/test/release-runtime-retention.test.js +357 -0
  15. package/test/rollbridge.test.js +3 -1
  16. package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +0 -28
  17. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +0 -13
  18. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +0 -1
  19. package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +0 -1
  20. package/tmp/worker-control/rollbridge-bootstrap/plan.md +0 -9
  21. package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +0 -3
  22. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +0 -25
  23. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +0 -1
  24. package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +0 -1
  25. package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +0 -14
  26. package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +0 -1
  27. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +0 -3
  28. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +0 -38
  29. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +0 -1
  30. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +0 -1
  31. package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +0 -1
  32. package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +0 -1
  33. package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +0 -1
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})))
@@ -64,11 +66,29 @@ export default class RollbridgeDaemon {
64
66
  this.proxy.on("error", (error, req, res) => this.onProxyError(error, req, res))
65
67
  }
66
68
 
67
- /** @returns {Promise<void>} Starts proxy and control listeners. */
68
- async start() {
69
+ /**
70
+ * Starts daemon listeners.
71
+ * @param {{exposeControl?: boolean}} [options] - Whether to expose the control socket immediately.
72
+ * @returns {Promise<void>} Resolves when the requested listeners are ready.
73
+ */
74
+ async start({exposeControl = true} = {}) {
69
75
  await this.reportOrphans()
70
76
  await this.startProxy()
77
+ if (exposeControl) await this.exposeControl()
78
+ }
79
+
80
+ /** @returns {Promise<void>} Exposes control commands and begins periodic state persistence. */
81
+ async exposeControl() {
82
+ if (this.stopping) throw new Error("Rollbridge is shutting down")
83
+
71
84
  await this.startControlServer()
85
+
86
+ if (this.stopping) {
87
+ await this.closeServer(this.controlServer)
88
+ await fs.rm(this.config.control.path, {force: true})
89
+ throw new Error("Rollbridge is shutting down")
90
+ }
91
+
72
92
  this.startStatePersistence()
73
93
  }
74
94
 
@@ -800,6 +820,7 @@ export default class RollbridgeDaemon {
800
820
  activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
801
821
  application: this.config.application,
802
822
  control: {...this.config.control},
823
+ daemonRuntime: this.runtime ? {...this.runtime} : undefined,
803
824
  orphans: [...this.orphans],
804
825
  proxy: {
805
826
  host: this.config.proxy.host,
@@ -44,6 +44,7 @@ test("completion bash prints a sourceable script with commands and option flags"
44
44
  assert.match(output, /compgen -W "daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
45
45
  // A command's own options are completed after the command.
46
46
  assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
47
+ assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
47
48
  assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
48
49
  })
49
50
 
@@ -47,7 +47,7 @@ test("daemon bootstrap activates the exact release through the foreground daemon
47
47
  const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
48
48
 
49
49
  try {
50
- await waitForLog(child, "traffic switched")
50
+ await waitForLog(child, "control socket listening")
51
51
  const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
52
52
  const activeRelease = assertRelease(status, "release-42")
53
53
 
@@ -64,6 +64,33 @@ test("daemon bootstrap activates the exact release through the foreground daemon
64
64
  }
65
65
  })
66
66
 
67
+ test("daemon bootstrap does not expose control deploys until activation completes", async () => {
68
+ const fixture = await createFixture({healthGate: true, healthTimeoutMs: 60000})
69
+ const started = waitForFile(fixture.startedPath)
70
+ const child = spawnDaemon(fixture, {releaseId: "bootstrap-release", revision: "bootstrap123"})
71
+
72
+ try {
73
+ await started
74
+ await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
75
+
76
+ await fs.writeFile(fixture.healthGatePath, "ready\n")
77
+ await waitForLog(child, "control socket listening")
78
+
79
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
80
+
81
+ assert.equal(status.activeReleaseId, "bootstrap-release")
82
+ assert.ok(Array.isArray(status.releases))
83
+ assert.equal(status.releases.length, 1)
84
+ assertRelease(status, "bootstrap-release")
85
+
86
+ child.kill("SIGTERM")
87
+ assert.equal((await once(child, "exit"))[0], 0)
88
+ } finally {
89
+ if (child.exitCode === null) child.kill("SIGKILL")
90
+ await fs.rm(fixture.root, {force: true, recursive: true})
91
+ }
92
+ })
93
+
67
94
  test("plain daemon startup remains listener-only with no active release", async () => {
68
95
  const fixture = await createFixture()
69
96
  const child = spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath], {stdio: ["pipe", "pipe", "pipe"]})
@@ -182,7 +209,7 @@ test("daemon bootstrap reports but does not kill a live process from statePath",
182
209
  const child = spawnDaemon(fixture, {releaseId: "recovered", revision: "def456"})
183
210
 
184
211
  try {
185
- await waitForLog(child, "traffic switched")
212
+ await waitForLog(child, "control socket listening")
186
213
  const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
187
214
 
188
215
  assert.ok(leftover.pid !== undefined && isProcessAlive(leftover.pid))
@@ -228,10 +255,10 @@ test("failed daemon bootstrap preserves prior live process records in statePath"
228
255
  })
229
256
 
230
257
  /**
231
- * @param {{healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
232
- * @returns {Promise<{configPath: string, gatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
258
+ * @param {{healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
259
+ * @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
233
260
  */
234
- async function createFixture({healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
261
+ async function createFixture({healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
235
262
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-bootstrap-"))
236
263
  const socketPath = path.join(root, "control.sock")
237
264
  const statePath = path.join(root, "state.json")
@@ -239,9 +266,15 @@ async function createFixture({healthPath = "/ping", healthTimeoutMs = 1000, mult
239
266
  const stoppedPath = path.join(root, "stopped.pid")
240
267
  const lifecyclePath = path.join(root, "lifecycle.jsonl")
241
268
  const gatePath = path.join(root, "continue.fifo")
269
+ const healthGatePath = path.join(root, "health-ready")
242
270
  const configPath = path.join(root, "rollbridge.js")
243
271
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
244
272
  const lifecycleEnv = {ROLLBRIDGE_TEST_LIFECYCLE_PATH: lifecyclePath}
273
+ const webEnv = {
274
+ ROLLBRIDGE_TEST_STARTED_PATH: startedPath,
275
+ ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath,
276
+ ...(healthGate ? {ROLLBRIDGE_TEST_HEALTH_GATE_PATH: healthGatePath} : {})
277
+ }
245
278
  const config = {
246
279
  application: "bootstrap-test",
247
280
  control: {path: socketPath},
@@ -249,7 +282,7 @@ async function createFixture({healthPath = "/ping", healthTimeoutMs = 1000, mult
249
282
  {command: `trap '' TERM; printf '%s\\n' '{"event":"shutdown"}' >> ${JSON.stringify(lifecyclePath)}; kill -TERM "$ROLLBRIDGE_TEST_DAEMON_PID"; printf '{"event":"started","pid":%s,"processId":"database","replicaIndex":"0"}\\n' "$$" >> ${JSON.stringify(lifecyclePath)}; read ignored < ${JSON.stringify(gatePath)}`, env: lifecycleEnv, id: "database", policy: "service"},
250
283
  {command: `exec ${command}`, env: lifecycleEnv, id: "worker", policy: "companion", replicas: 2},
251
284
  {command: `exec ${command}`, env: lifecycleEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
252
- ] : [{command, env: {ROLLBRIDGE_TEST_STARTED_PATH: startedPath, ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath}, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
285
+ ] : [{command, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
253
286
  proxy: {host: "127.0.0.1", port: 0},
254
287
  ...(persistState ? {statePath} : {})
255
288
  }
@@ -264,7 +297,7 @@ async function createFixture({healthPath = "/ping", healthTimeoutMs = 1000, mult
264
297
  }
265
298
 
266
299
  await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
267
- return {configPath, gatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
300
+ return {configPath, gatePath, healthGatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
268
301
  }
269
302
 
270
303
  /**
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import test from "node:test"
8
+ import {prepareDaemonRuntime} from "../src/daemon-runtime.js"
9
+
10
+ test("concurrent runtime preparation converges on one validated content-addressed snapshot", async () => {
11
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-concurrent-"))
12
+
13
+ try {
14
+ const identities = await Promise.all([
15
+ prepareDaemonRuntime(root),
16
+ prepareDaemonRuntime(root),
17
+ prepareDaemonRuntime(root)
18
+ ])
19
+
20
+ assert.deepEqual(identities, [identities[0], identities[0], identities[0]])
21
+ assert.match(identities[0].digest, /^[a-f0-9]{64}$/)
22
+ assert.equal(path.dirname(identities[0].path), root)
23
+ assert.equal(JSON.parse(await fs.readFile(path.join(identities[0].path, "runtime.json"), "utf8")).digest, identities[0].digest)
24
+
25
+ const entries = (await fs.readdir(root)).filter((entry) => entry.startsWith(".prepare-"))
26
+ assert.deepEqual(entries, [])
27
+ } finally {
28
+ await fs.rm(root, {force: true, recursive: true})
29
+ }
30
+ })
31
+
32
+ test("preparation fails closed when an existing content-addressed snapshot is corrupt", async () => {
33
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-corrupt-"))
34
+
35
+ try {
36
+ const identity = await prepareDaemonRuntime(root)
37
+
38
+ await fs.writeFile(path.join(identity.path, "src", "daemon.js"), "corrupt\n")
39
+ await assert.rejects(() => prepareDaemonRuntime(root), /runtime validation failed/)
40
+ } finally {
41
+ await fs.rm(root, {force: true, recursive: true})
42
+ }
43
+ })
44
+
45
+ test("runtime preparation rejects a symlinked or shared-writable parent", async (t) => {
46
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-permissions-"))
47
+ const target = path.join(root, "target")
48
+ const symlink = path.join(root, "symlink")
49
+ const shared = path.join(root, "shared")
50
+
51
+ try {
52
+ await fs.mkdir(target)
53
+ await fs.symlink(target, symlink, "dir")
54
+ await assert.rejects(() => prepareDaemonRuntime(symlink), /must be a real directory/)
55
+
56
+ if (process.platform === "win32") {
57
+ t.skip("POSIX directory permissions are not available on Windows")
58
+ return
59
+ }
60
+
61
+ await fs.mkdir(shared, {mode: 0o777})
62
+ await fs.chmod(shared, 0o777)
63
+ await assert.rejects(() => prepareDaemonRuntime(shared), /must not be writable by group or other users/)
64
+ } finally {
65
+ await fs.rm(root, {force: true, recursive: true})
66
+ }
67
+ })
68
+
69
+ test("runtime preparation rejects a private leaf beneath a replaceable ancestor", async (t) => {
70
+ if (process.platform === "win32") {
71
+ t.skip("POSIX directory permissions are not available on Windows")
72
+ return
73
+ }
74
+
75
+ const unsafeAncestor = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-unsafe-ancestor-"))
76
+ const privateLeaf = path.join(unsafeAncestor, "private-runtime")
77
+
78
+ try {
79
+ await fs.chmod(unsafeAncestor, 0o777)
80
+ await fs.mkdir(privateLeaf, {mode: 0o700})
81
+
82
+ await assert.rejects(
83
+ () => prepareDaemonRuntime(privateLeaf),
84
+ /ancestor must be sticky or not writable by group or other users/
85
+ )
86
+ } finally {
87
+ await fs.chmod(unsafeAncestor, 0o700)
88
+ await fs.rm(unsafeAncestor, {force: true, recursive: true})
89
+ }
90
+ })
@@ -15,7 +15,7 @@ if (process.env.ROLLBRIDGE_TEST_STARTED_PATH) {
15
15
 
16
16
  const server = http.createServer((request, response) => {
17
17
  if (request.url === "/ping") {
18
- if (healthFails) {
18
+ if (healthFails || (process.env.ROLLBRIDGE_TEST_HEALTH_GATE_PATH && !fs.existsSync(process.env.ROLLBRIDGE_TEST_HEALTH_GATE_PATH))) {
19
19
  response.writeHead(500, {"Content-Type": "application/json"})
20
20
  response.end(JSON.stringify({message: "bad release"}))
21
21
  return
@@ -1,12 +1,16 @@
1
1
  // @ts-check
2
2
 
3
3
  import assert from "node:assert/strict"
4
+ import {execFile} from "node:child_process"
4
5
  import fs from "node:fs/promises"
6
+ import os from "node:os"
5
7
  import path from "node:path"
6
8
  import test from "node:test"
7
9
  import {fileURLToPath} from "node:url"
10
+ import {promisify} from "node:util"
8
11
 
9
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
13
+ const execFileAsync = promisify(execFile)
10
14
 
11
15
  test("package.json declares publish metadata", async () => {
12
16
  const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
@@ -27,3 +31,38 @@ test("a LICENSE file matching the declared license exists", async () => {
27
31
  assert.match(license, /MIT License/)
28
32
  assert.match(license, /Copyright \(c\) \d{4} kaspernj/)
29
33
  })
34
+
35
+ test("package manifest excludes unexpected operational and coverage files", async (t) => {
36
+ const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-pack-"))
37
+ t.after(() => fs.rm(fixtureRoot, {force: true, recursive: true}))
38
+
39
+ await fs.cp(repoRoot, fixtureRoot, {
40
+ filter: (source) => {
41
+ const relative = path.relative(repoRoot, source)
42
+ return relative !== ".git" && relative !== "node_modules" && relative !== "tmp"
43
+ },
44
+ recursive: true,
45
+ })
46
+
47
+ const unexpectedTmpPath = path.join(fixtureRoot, "tmp", "worker-control", "unexpected-transcript.jsonl")
48
+ const unexpectedCoveragePath = path.join(fixtureRoot, "coverage", "unexpected.txt")
49
+ await Promise.all([
50
+ fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
51
+ fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
52
+ ])
53
+ await Promise.all([
54
+ fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
55
+ fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
56
+ ])
57
+
58
+ const {stdout} = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {cwd: fixtureRoot})
59
+ /** @type {Array<{path: string}>} */
60
+ const packageFiles = JSON.parse(stdout)[0].files
61
+ const packagePaths = packageFiles.map((file) => file.path)
62
+
63
+ for (const requiredPath of ["LICENSE", "README.md", "bin/rollbridge", "package.json", "src/cli.js", "src/daemon-runtime.js"]) {
64
+ assert.ok(packagePaths.includes(requiredPath), `expected package to include ${requiredPath}`)
65
+ }
66
+ assert.ok(!packagePaths.some((packagePath) => packagePath === "tmp" || packagePath.startsWith("tmp/")))
67
+ assert.ok(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/")))
68
+ })