rollbridge 0.1.2 → 0.1.5

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,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)
@@ -90,6 +141,38 @@ test("singleton processes restart without overlap during deploy", async () => {
90
141
  }
91
142
  })
92
143
 
144
+ test("a failed singleton replacement surfaces the error after stopping the old singleton", async () => {
145
+ // The singleton's working directory is per-release; only the v1 directory exists, so
146
+ // the v2 replacement cannot spawn (ENOENT on cwd) and its start() rejects.
147
+ const fixture = await createFixture({includeSingleton: true, singletonCwd: "{{releasePath}}/{{releaseId}}"})
148
+ const daemon = await startDaemon(fixture.config)
149
+
150
+ await fs.mkdir(path.join(fixture.root, "v1"))
151
+
152
+ try {
153
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
154
+ await waitFor(async () => (await processEvents(fixture.singletonLogPath)).some((event) => event.event === "start" && event.releaseId === "v1"))
155
+
156
+ // The new release's singleton fails to start, so the deploy surfaces the error.
157
+ await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}))
158
+
159
+ // The old singleton is stopped before the new one is started, so two copies never
160
+ // overlap — even when the replacement then fails.
161
+ await waitFor(async () => (await processEvents(fixture.singletonLogPath)).some((event) => event.event === "stop" && event.releaseId === "v1"))
162
+
163
+ const status = daemon.status()
164
+
165
+ // Traffic switches before singletons are replaced, so the new release is already active,
166
+ // but its singleton is left failed with no replacement running.
167
+ assert.equal(status.activeReleaseId, "v2")
168
+ assert.equal(status.singletons.length, 1)
169
+ assert.equal(status.singletons[0].process.state, "failed")
170
+ } finally {
171
+ await daemon.shutdown()
172
+ await fs.rm(fixture.root, {force: true, recursive: true})
173
+ }
174
+ })
175
+
93
176
  test("service processes start before releases and restart with the latest deploy template", async () => {
94
177
  const fixture = await createFixture({includeService: true, webDependsOnService: true})
95
178
  const daemon = await startDaemon(fixture.config)
@@ -122,6 +205,137 @@ test("service processes start before releases and restart with the latest deploy
122
205
  }
123
206
  })
124
207
 
208
+ test("restart bounces a single process by id", async () => {
209
+ const fixture = await createFixture({includeService: true})
210
+ const daemon = await startDaemon(fixture.config)
211
+
212
+ try {
213
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
214
+
215
+ const before = pidsById(daemon.status())
216
+ const result = await daemon.restartProcesses({processId: "beacon"})
217
+
218
+ assert.deepEqual(result.restarted, ["beacon"])
219
+
220
+ const after = pidsById(daemon.status())
221
+
222
+ assert.ok(before.beacon && after.beacon, "beacon should have a pid before and after")
223
+ assert.notEqual(after.beacon, before.beacon)
224
+ } finally {
225
+ await daemon.shutdown()
226
+ await fs.rm(fixture.root, {force: true, recursive: true})
227
+ }
228
+ })
229
+
230
+ test("restart with no selector bounces every non-proxied process but not the proxied one", async () => {
231
+ const fixture = await createFixture({includeCompanion: true, includeService: true, includeSingleton: true})
232
+ const daemon = await startDaemon(fixture.config)
233
+
234
+ try {
235
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
236
+
237
+ const before = pidsById(daemon.status())
238
+ const result = await daemon.restartProcesses()
239
+ const restarted = /** @type {string[]} */ (result.restarted)
240
+
241
+ assert.deepEqual([...restarted].sort(), ["beacon", "jobs-main", "worker"])
242
+
243
+ const after = pidsById(daemon.status())
244
+
245
+ assert.equal(after.web, before.web, "proxied process should not be restarted")
246
+ assert.notEqual(after.beacon, before.beacon)
247
+ assert.notEqual(after["jobs-main"], before["jobs-main"])
248
+ assert.notEqual(after.worker, before.worker)
249
+ } finally {
250
+ await daemon.shutdown()
251
+ await fs.rm(fixture.root, {force: true, recursive: true})
252
+ }
253
+ })
254
+
255
+ test("restart --policy targets only processes with that policy", async () => {
256
+ const fixture = await createFixture({includeCompanion: true, includeService: true})
257
+ const daemon = await startDaemon(fixture.config)
258
+
259
+ try {
260
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
261
+
262
+ const before = pidsById(daemon.status())
263
+ const result = await daemon.restartProcesses({policy: "companion"})
264
+
265
+ assert.deepEqual(result.restarted, ["worker"])
266
+
267
+ const after = pidsById(daemon.status())
268
+
269
+ assert.notEqual(after.worker, before.worker)
270
+ assert.equal(after.beacon, before.beacon, "the service should be left running")
271
+ } finally {
272
+ await daemon.shutdown()
273
+ await fs.rm(fixture.root, {force: true, recursive: true})
274
+ }
275
+ })
276
+
277
+ test("restart refuses the proxied process and reports unknown ids", async () => {
278
+ const fixture = await createFixture()
279
+ const daemon = await startDaemon(fixture.config)
280
+
281
+ try {
282
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
283
+
284
+ await assert.rejects(() => daemon.restartProcesses({processId: "web"}), /proxied process cannot be restarted/)
285
+ await assert.rejects(() => daemon.restartProcesses({policy: "proxied"}), /proxied process cannot be restarted/)
286
+ await assert.rejects(() => daemon.restartProcesses({processId: "missing"}), /No managed process with id "missing"/)
287
+ } finally {
288
+ await daemon.shutdown()
289
+ await fs.rm(fixture.root, {force: true, recursive: true})
290
+ }
291
+ })
292
+
293
+ test("restart revives a stopped process instead of erroring", async () => {
294
+ const fixture = await createFixture({includeCompanion: true})
295
+ const daemon = await startDaemon(fixture.config)
296
+
297
+ try {
298
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
299
+
300
+ // Simulate the worker having exited (e.g. crashed and exhausted its restart budget).
301
+ const worker = daemon.activeRelease?.getProcess("worker")
302
+
303
+ assert.ok(worker, "worker process should exist")
304
+ await worker.stop()
305
+ assert.equal(worker.status().state, "stopped")
306
+
307
+ const result = await daemon.restartProcesses({processId: "worker"})
308
+
309
+ assert.deepEqual(result.restarted, ["worker"])
310
+ assert.equal(worker.status().state, "running")
311
+ assert.ok(worker.status().pid)
312
+ } finally {
313
+ await daemon.shutdown()
314
+ await fs.rm(fixture.root, {force: true, recursive: true})
315
+ }
316
+ })
317
+
318
+ test("the restart control command bounces a process over the socket", async () => {
319
+ const fixture = await createFixture({includeService: true})
320
+ const daemon = await startDaemon(fixture.config)
321
+
322
+ try {
323
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
324
+
325
+ const before = pidsById(daemon.status())
326
+ const response = await sendControlCommand({
327
+ command: {command: "restart", processId: "beacon"},
328
+ path: fixture.config.control.path
329
+ })
330
+
331
+ assert.deepEqual(response.restarted, ["beacon"])
332
+ assert.notEqual(pidsById(daemon.status()).beacon, before.beacon)
333
+ } finally {
334
+ await daemon.shutdown()
335
+ await fs.rm(fixture.root, {force: true, recursive: true})
336
+ }
337
+ })
338
+
125
339
  test("control socket accepts deploy and status commands", async () => {
126
340
  const fixture = await createFixture()
127
341
  const daemon = await startDaemon(fixture.config)
@@ -285,7 +499,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
285
499
  })
286
500
 
287
501
  /**
288
- * @param {{includeService?: boolean, includeSingleton?: boolean, webDependsOnService?: boolean}} [options] - Fixture options.
502
+ * @param {{includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number}} [options] - Fixture options.
289
503
  * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string}>} Fixture data.
290
504
  */
291
505
  async function createFixture(options = {}) {
@@ -308,14 +522,22 @@ async function createFixture(options = {}) {
308
522
  })
309
523
  }
310
524
 
525
+ if (options.includeCompanion) {
526
+ processes.push({
527
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
528
+ id: "worker",
529
+ policy: "companion"
530
+ })
531
+ }
532
+
311
533
  processes.push({
312
- command: options.webDependsOnService
534
+ command: options.webCommand || (options.webDependsOnService
313
535
  ? `${JSON.stringify(process.execPath)} ${JSON.stringify(dependentAppPath)}`
314
- : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
536
+ : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`),
315
537
  health: {
316
538
  intervalMs: 50,
317
539
  path: "/ping",
318
- timeoutMs: 3000
540
+ timeoutMs: options.webHealthTimeoutMs || 3000
319
541
  },
320
542
  id: "web",
321
543
  policy: "proxied",
@@ -325,6 +547,7 @@ async function createFixture(options = {}) {
325
547
  if (options.includeSingleton) {
326
548
  processes.push({
327
549
  command: `${JSON.stringify(process.execPath)} ${JSON.stringify(singletonAppPath)}`,
550
+ ...(options.singletonCwd ? {cwd: options.singletonCwd} : {}),
328
551
  env: {
329
552
  ROLLBRIDGE_SINGLETON_LOG: singletonLogPath
330
553
  },
@@ -344,7 +567,7 @@ async function createFixture(options = {}) {
344
567
  forceStopTimeoutMs: 500,
345
568
  healthPath: "/ping",
346
569
  healthTimeoutMs: 3000,
347
- host: "127.0.0.1",
570
+ host: options.proxyHost || "127.0.0.1",
348
571
  port: 0
349
572
  }
350
573
  })
@@ -415,6 +638,27 @@ function statusRelease(daemon, releaseId) {
415
638
  return release
416
639
  }
417
640
 
641
+ /**
642
+ * Maps process id to pid across the active release, services, and singletons.
643
+ * @param {import("../src/daemon.js").DaemonStatus} status - Daemon status payload.
644
+ * @returns {Record<string, number | undefined>} Process id to current pid.
645
+ */
646
+ function pidsById(status) {
647
+ /** @type {Record<string, number | undefined>} */
648
+ const pids = {}
649
+
650
+ for (const release of status.releases) {
651
+ if (release.state !== "active") continue
652
+
653
+ for (const processStatus of release.processes) pids[processStatus.id] = processStatus.pid
654
+ }
655
+
656
+ for (const service of status.services) pids[service.id] = service.process.pid
657
+ for (const singleton of status.singletons) pids[singleton.id] = singleton.process.pid
658
+
659
+ return pids
660
+ }
661
+
418
662
  /**
419
663
  * @param {string} logPath - Log path.
420
664
  * @returns {Promise<Array<{event: string, pid: number, releaseId: string}>>} Events.
@@ -1,83 +0,0 @@
1
- #!/usr/bin/env node
2
- import {execFileSync} from "node:child_process"
3
-
4
- /**
5
- * Runs a command and inherits stdio.
6
- * @param {string} command - Command to run.
7
- * @param {string[]} [args] - Command arguments.
8
- * @returns {void}
9
- */
10
- function run(command, args = []) {
11
- execFileSync(command, args, {
12
- env: {
13
- ...process.env,
14
- GIT_EDITOR: "true",
15
- GIT_MERGE_AUTOEDIT: "no"
16
- },
17
- stdio: "inherit"
18
- })
19
- }
20
-
21
- /**
22
- * Runs a command and returns trimmed stdout.
23
- * @param {string} command - Command to run.
24
- * @param {string[]} [args] - Command arguments.
25
- * @returns {string} Trimmed stdout.
26
- */
27
- function output(command, args = []) {
28
- return execFileSync(command, args, {encoding: "utf8"}).trim()
29
- }
30
-
31
- /** @returns {string} GitHub remote default branch name. */
32
- function defaultBranch() {
33
- const remoteHead = output("git", ["ls-remote", "--symref", "origin", "HEAD"])
34
- const match = remoteHead.match(/^ref: refs\/heads\/(.+)\s+HEAD$/m)
35
-
36
- if (!match) throw new Error("Unable to determine origin default branch")
37
-
38
- return match[1]
39
- }
40
-
41
- /**
42
- * @param {string} branch - Branch name.
43
- * @returns {boolean} True when the local branch exists.
44
- */
45
- function localBranchExists(branch) {
46
- try {
47
- output("git", ["rev-parse", "--verify", `refs/heads/${branch}`])
48
- return true
49
- } catch (_error) {
50
- return false
51
- }
52
- }
53
-
54
- /** @returns {string} Updated default branch name. */
55
- function updateLocalDefaultBranch() {
56
- run("git", ["fetch", "origin"])
57
- const branch = defaultBranch()
58
-
59
- if (localBranchExists(branch)) {
60
- run("git", ["checkout", branch])
61
- } else {
62
- run("git", ["checkout", "-b", branch, `origin/${branch}`])
63
- }
64
-
65
- run("git", ["merge", "--ff-only", `origin/${branch}`])
66
-
67
- return branch
68
- }
69
-
70
- try {
71
- execFileSync("npm", ["whoami"], {stdio: "ignore"})
72
- } catch {
73
- run("npm", ["login"])
74
- }
75
-
76
- const branch = updateLocalDefaultBranch()
77
-
78
- run("npm", ["version", "patch", "--no-git-tag-version"])
79
- run("npm", ["install"])
80
- run("git", ["add", "package.json", "package-lock.json"])
81
- run("git", ["commit", "-m", "chore: bump patch version"])
82
- run("git", ["push", "origin", branch])
83
- run("npm", ["publish"])