rollbridge 0.1.1 → 0.1.4

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.
@@ -0,0 +1,128 @@
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 {fileURLToPath} from "node:url"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+
12
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
13
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
14
+
15
+ /**
16
+ * @param {string} root - Fixture root used for the control socket.
17
+ * @param {number} restartDelayMs - Delay before a crashed process is restarted.
18
+ * @returns {import("../src/config.js").RollbridgeConfig} Normalized config with one proxied web process.
19
+ */
20
+ function buildConfig(root, restartDelayMs) {
21
+ return normalizeConfig({
22
+ application: "rollbridge-proxy-test",
23
+ control: {path: path.join(root, "rollbridge.sock")},
24
+ processes: [
25
+ {
26
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
27
+ health: {intervalMs: 50, path: "/ping", timeoutMs: 3000},
28
+ id: "web",
29
+ policy: "proxied",
30
+ port: {from: 0, to: 0},
31
+ restartDelayMs
32
+ }
33
+ ],
34
+ proxy: {drainTimeoutMs: 1000, forceStopTimeoutMs: 500, healthPath: "/ping", healthTimeoutMs: 3000, host: "127.0.0.1", port: 0}
35
+ })
36
+ }
37
+
38
+ /**
39
+ * @param {RollbridgeDaemon} daemon - Daemon.
40
+ * @param {string} pathName - Request path.
41
+ * @returns {Promise<Response>} Proxy response.
42
+ */
43
+ async function proxyFetch(daemon, pathName) {
44
+ return await fetch(`http://127.0.0.1:${daemon.getProxyPort()}${pathName}`)
45
+ }
46
+
47
+ /**
48
+ * @param {RollbridgeDaemon} daemon - Daemon.
49
+ * @param {string} releaseId - Active release id.
50
+ * @returns {number} The web process pid reported by status.
51
+ */
52
+ function webPid(daemon, releaseId) {
53
+ const release = daemon.status().releases.find((candidate) => candidate.releaseId === releaseId)
54
+
55
+ assert.ok(release, `Release ${releaseId} should be present`)
56
+
57
+ const web = release.processes.find((candidate) => candidate.id === "web")
58
+
59
+ assert.ok(web && typeof web.pid === "number", "web process should report a pid")
60
+
61
+ return web.pid
62
+ }
63
+
64
+ /**
65
+ * @param {() => Promise<boolean> | boolean} callback - Probe.
66
+ * @returns {Promise<void>} Resolves once the probe returns true.
67
+ */
68
+ async function waitFor(callback) {
69
+ const deadline = Date.now() + 3000
70
+
71
+ while (Date.now() < deadline) {
72
+ if (await callback()) return
73
+ await new Promise((resolve) => setTimeout(resolve, 25))
74
+ }
75
+
76
+ throw new Error("Timed out waiting for condition")
77
+ }
78
+
79
+ test("proxy returns 502 while the active release web process is down", async () => {
80
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-proxy-"))
81
+ const daemon = new RollbridgeDaemon({config: buildConfig(root, 60000), logger: () => {}})
82
+
83
+ await daemon.start()
84
+
85
+ try {
86
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
87
+ assert.equal((await proxyFetch(daemon, "/release")).status, 200)
88
+
89
+ // Kill the web process group so the active release exits unexpectedly; restart is held off for 60s.
90
+ process.kill(-webPid(daemon, "v1"), "SIGKILL")
91
+
92
+ let lastStatus = 0
93
+
94
+ await waitFor(async () => {
95
+ lastStatus = (await proxyFetch(daemon, "/release")).status
96
+
97
+ return lastStatus === 502
98
+ })
99
+
100
+ assert.equal(lastStatus, 502)
101
+ } finally {
102
+ await daemon.shutdown()
103
+ await fs.rm(root, {force: true, recursive: true})
104
+ }
105
+ })
106
+
107
+ test("proxy recovers once the crashed web process restarts", async () => {
108
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-proxy-"))
109
+ const daemon = new RollbridgeDaemon({config: buildConfig(root, 300), logger: () => {}})
110
+
111
+ await daemon.start()
112
+
113
+ try {
114
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
115
+ assert.equal((await proxyFetch(daemon, "/release")).status, 200)
116
+
117
+ process.kill(-webPid(daemon, "v1"), "SIGKILL")
118
+
119
+ // First confirm the crash actually takes the proxy down (502), so passing requires
120
+ // observing the outage, then confirm the restart brings the proxy back to 200.
121
+ await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 502)
122
+ await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 200)
123
+ assert.equal((await proxyFetch(daemon, "/release")).status, 200)
124
+ } finally {
125
+ await daemon.shutdown()
126
+ await fs.rm(root, {force: true, recursive: true})
127
+ }
128
+ })
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import test from "node:test"
5
+ import ReleaseGroup from "../src/release-group.js"
6
+ import {normalizeConfig} from "../src/config.js"
7
+
8
+ /**
9
+ * @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
10
+ * @returns {ReleaseGroup} A release group ready for buildProcess.
11
+ */
12
+ function buildRelease(webProcess) {
13
+ const config = normalizeConfig({
14
+ application: "demo",
15
+ control: {path: "/tmp/rollbridge-release-group.sock"},
16
+ processes: [webProcess],
17
+ proxy: {host: "127.0.0.1", port: 0}
18
+ })
19
+
20
+ return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1"})
21
+ }
22
+
23
+ test("templates interpolate values from the daemon environment", () => {
24
+ const release = buildRelease({
25
+ command: "run --token {{env.ROLLBRIDGE_ENV_TEST}}",
26
+ env: {DOWNSTREAM_TOKEN: "{{env.ROLLBRIDGE_ENV_TEST}}"},
27
+ id: "web",
28
+ policy: "proxied",
29
+ port: {from: 0, to: 0}
30
+ })
31
+
32
+ process.env.ROLLBRIDGE_ENV_TEST = "from-daemon"
33
+
34
+ try {
35
+ const managed = release.buildProcess(release.config.processes[0])
36
+
37
+ assert.equal(managed.command, "run --token from-daemon")
38
+ assert.equal(managed.env.DOWNSTREAM_TOKEN, "from-daemon")
39
+ } finally {
40
+ delete process.env.ROLLBRIDGE_ENV_TEST
41
+ }
42
+ })
43
+
44
+ test("a referenced daemon environment variable that is unset fails fast", () => {
45
+ const release = buildRelease({
46
+ command: "run {{env.ROLLBRIDGE_ENV_MISSING}}",
47
+ id: "web",
48
+ policy: "proxied",
49
+ port: {from: 0, to: 0}
50
+ })
51
+
52
+ delete process.env.ROLLBRIDGE_ENV_MISSING
53
+
54
+ assert.throws(
55
+ () => release.buildProcess(release.config.processes[0]),
56
+ /Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
57
+ )
58
+ })
@@ -0,0 +1,107 @@
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 {fileURLToPath} from "node:url"
9
+ import RollbridgeDaemon, {releasesToPrune} from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+
12
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
13
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
14
+
15
+ test("releasesToPrune keeps the most recent stopped releases and never active or draining ones", () => {
16
+ const releases = [
17
+ {releaseId: "active", state: "active", stoppedAt: undefined},
18
+ {releaseId: "draining", state: "draining", stoppedAt: undefined},
19
+ {releaseId: "v3", state: "stopped", stoppedAt: "2026-05-22T00:00:03.000Z"},
20
+ {releaseId: "v2", state: "stopped", stoppedAt: "2026-05-22T00:00:02.000Z"},
21
+ {releaseId: "v1", state: "stopped", stoppedAt: "2026-05-22T00:00:01.000Z"}
22
+ ]
23
+
24
+ const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
25
+
26
+ assert.deepEqual([...remove].sort(), ["v1", "v2"])
27
+ })
28
+
29
+ test("releasesToPrune keeps the later-deployed release when stoppedAt ties", () => {
30
+ const sameTime = "2026-05-22T00:00:05.000Z"
31
+ // Deploy order (oldest first): v1 then v2, both stopped in the same millisecond.
32
+ const releases = [
33
+ {releaseId: "v1", state: "stopped", stoppedAt: sameTime},
34
+ {releaseId: "v2", state: "stopped", stoppedAt: sameTime}
35
+ ]
36
+
37
+ const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
38
+
39
+ assert.deepEqual(remove, ["v1"])
40
+ })
41
+
42
+ test("releasesToPrune prunes stopped releases older than maxAgeMs", () => {
43
+ const now = Date.parse("2026-05-22T00:01:00.000Z")
44
+ const releases = [
45
+ {releaseId: "fresh", state: "stopped", stoppedAt: new Date(now - 1000).toISOString()},
46
+ {releaseId: "old", state: "stopped", stoppedAt: new Date(now - 60000).toISOString()}
47
+ ]
48
+
49
+ const remove = releasesToPrune(releases, {keep: 100, maxAgeMs: 30000}, now)
50
+
51
+ assert.deepEqual(remove, ["old"])
52
+ })
53
+
54
+ test("the daemon prunes stopped releases beyond the retention count across deploys", async () => {
55
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-retention-"))
56
+ const config = normalizeConfig({
57
+ application: "rollbridge-retention-test",
58
+ control: {path: path.join(root, "rollbridge.sock")},
59
+ processes: [
60
+ {
61
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
62
+ health: {intervalMs: 50, path: "/ping", timeoutMs: 3000},
63
+ id: "web",
64
+ policy: "proxied",
65
+ port: {from: 0, to: 0}
66
+ }
67
+ ],
68
+ proxy: {drainTimeoutMs: 200, forceStopTimeoutMs: 200, host: "127.0.0.1", port: 0},
69
+ releaseRetention: {keep: 1}
70
+ })
71
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
72
+
73
+ await daemon.start()
74
+
75
+ try {
76
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
77
+ await daemon.deploy({releaseId: "v2", releasePath: root, revision: "v2"})
78
+ await daemon.deploy({releaseId: "v3", releasePath: root, revision: "v3"})
79
+
80
+ // Older stopped releases are pruned once they drain; keep:1 retains only the most recent stopped one.
81
+ await waitFor(() => !daemon.status().releases.some((release) => release.releaseId === "v1"))
82
+
83
+ const ids = daemon.status().releases.map((release) => release.releaseId)
84
+
85
+ assert.ok(ids.includes("v3"), `active release should be retained, got ${JSON.stringify(ids)}`)
86
+ assert.ok(!ids.includes("v1"), `oldest stopped release should be pruned, got ${JSON.stringify(ids)}`)
87
+ assert.ok(ids.length <= 2, `expected at most the active release plus one stopped, got ${JSON.stringify(ids)}`)
88
+ } finally {
89
+ await daemon.shutdown()
90
+ await fs.rm(root, {force: true, recursive: true})
91
+ }
92
+ })
93
+
94
+ /**
95
+ * @param {() => boolean} callback - Probe.
96
+ * @returns {Promise<void>} Resolves once the probe returns true.
97
+ */
98
+ async function waitFor(callback) {
99
+ const deadline = Date.now() + 3000
100
+
101
+ while (Date.now() < deadline) {
102
+ if (callback()) return
103
+ await new Promise((resolve) => setTimeout(resolve, 25))
104
+ }
105
+
106
+ throw new Error("Timed out waiting for condition")
107
+ }
@@ -64,6 +64,57 @@ test("failed health check leaves the previous release active", async () => {
64
64
  }
65
65
  })
66
66
 
67
+ test("wildcard proxy bind host targets release processes through loopback", async () => {
68
+ const fixture = await createFixture({proxyHost: "0.0.0.0"})
69
+ const daemon = await startDaemon(fixture.config)
70
+
71
+ try {
72
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
73
+
74
+ const status = daemon.status()
75
+ const release = statusRelease(daemon, "v1")
76
+
77
+ assert.ok(daemon.activeRelease, "expected active release")
78
+ assert.equal(status.proxy.host, "0.0.0.0")
79
+ assert.equal(status.proxy.upstreamHost, "127.0.0.1")
80
+ assert.equal(daemon.activeRelease.proxyTarget().target, `http://127.0.0.1:${release.ports.web}`)
81
+ assert.equal(await fetchText(daemon, "/release"), "v1")
82
+ } finally {
83
+ await daemon.shutdown()
84
+ await fs.rm(fixture.root, {force: true, recursive: true})
85
+ }
86
+ })
87
+
88
+ test("failed release startup logs process output before cleanup", async () => {
89
+ const fixture = await createFixture({webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
90
+ /** @type {Array<{data?: Record<string, import("../src/json.js").JsonValue>, message: string}>} */
91
+ const logs = []
92
+ const daemon = new RollbridgeDaemon({
93
+ config: fixture.config,
94
+ logger: (message, data = {}) => logs.push({data, message})
95
+ })
96
+
97
+ await daemon.start()
98
+
99
+ try {
100
+ await assert.rejects(
101
+ () => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}),
102
+ /Health check failed/
103
+ )
104
+
105
+ const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.processId === "web")
106
+
107
+ assert.ok(processStatusLog, "expected failed web process diagnostics to be logged")
108
+ assert.ok(processStatusLog.data, "expected diagnostic data")
109
+ assert.ok(Array.isArray(processStatusLog.data.logs), "expected retained process output in diagnostics")
110
+ assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stdout"))
111
+ assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stderr"))
112
+ } finally {
113
+ await daemon.shutdown()
114
+ await fs.rm(fixture.root, {force: true, recursive: true})
115
+ }
116
+ })
117
+
67
118
  test("singleton processes restart without overlap during deploy", async () => {
68
119
  const fixture = await createFixture({includeSingleton: true})
69
120
  const daemon = await startDaemon(fixture.config)
@@ -213,6 +264,29 @@ test("a control socket held by a non-Rollbridge process reports a generic confli
213
264
  }
214
265
  })
215
266
 
267
+ test("applies the configured control socket permission mode", async () => {
268
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
269
+ const socketPath = path.join(root, "rollbridge.sock")
270
+ const config = normalizeConfig({
271
+ application: "rollbridge-test",
272
+ control: {mode: "660", path: socketPath},
273
+ processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
274
+ proxy: {host: "127.0.0.1", port: 0}
275
+ })
276
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
277
+
278
+ await daemon.start()
279
+
280
+ try {
281
+ const stats = await fs.stat(socketPath)
282
+
283
+ assert.equal(stats.mode & 0o777, 0o660)
284
+ } finally {
285
+ await daemon.shutdown()
286
+ await fs.rm(root, {force: true, recursive: true})
287
+ }
288
+ })
289
+
216
290
  test("deploy can ensure the daemon before sending the release command", async () => {
217
291
  const fixture = await createFixture()
218
292
  const configPath = await writeConfigFile(fixture.config, fixture.root)
@@ -262,7 +336,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
262
336
  })
263
337
 
264
338
  /**
265
- * @param {{includeService?: boolean, includeSingleton?: boolean, webDependsOnService?: boolean}} [options] - Fixture options.
339
+ * @param {{includeService?: boolean, includeSingleton?: boolean, proxyHost?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number}} [options] - Fixture options.
266
340
  * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string}>} Fixture data.
267
341
  */
268
342
  async function createFixture(options = {}) {
@@ -286,13 +360,13 @@ async function createFixture(options = {}) {
286
360
  }
287
361
 
288
362
  processes.push({
289
- command: options.webDependsOnService
363
+ command: options.webCommand || (options.webDependsOnService
290
364
  ? `${JSON.stringify(process.execPath)} ${JSON.stringify(dependentAppPath)}`
291
- : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
365
+ : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`),
292
366
  health: {
293
367
  intervalMs: 50,
294
368
  path: "/ping",
295
- timeoutMs: 3000
369
+ timeoutMs: options.webHealthTimeoutMs || 3000
296
370
  },
297
371
  id: "web",
298
372
  policy: "proxied",
@@ -321,7 +395,7 @@ async function createFixture(options = {}) {
321
395
  forceStopTimeoutMs: 500,
322
396
  healthPath: "/ping",
323
397
  healthTimeoutMs: 3000,
324
- host: "127.0.0.1",
398
+ host: options.proxyHost || "127.0.0.1",
325
399
  port: 0
326
400
  }
327
401
  })