rollbridge 0.1.1

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,444 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import net from "node:net"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import test from "node:test"
9
+ import {fileURLToPath} from "node:url"
10
+ import RollbridgeDaemon from "../src/daemon.js"
11
+ import {normalizeConfig} from "../src/config.js"
12
+ import {sendControlCommand} from "../src/control-client.js"
13
+ import {runCli} from "../src/cli.js"
14
+
15
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
16
+ const binPath = path.join(currentDir, "..", "bin", "rollbridge")
17
+ const dependentAppPath = path.join(currentDir, "fixtures", "dependent-app.js")
18
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
19
+ const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
20
+ const singletonAppPath = path.join(currentDir, "fixtures", "singleton-app.js")
21
+
22
+ test("deploy switches new HTTP traffic while old WebSockets drain", async () => {
23
+ const fixture = await createFixture()
24
+ const daemon = await startDaemon(fixture.config)
25
+
26
+ try {
27
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
28
+ assert.equal(await fetchText(daemon, "/release"), "v1")
29
+
30
+ const websocket = await openWebSocket(daemon)
31
+
32
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
33
+ assert.equal(await fetchText(daemon, "/release"), "v2")
34
+
35
+ const drainingRelease = statusRelease(daemon, "v1")
36
+ assert.equal(drainingRelease.state, "draining")
37
+ assert.equal(drainingRelease.connections.websocket, 1)
38
+
39
+ websocket.close()
40
+ await waitFor(async () => statusRelease(daemon, "v1").state === "stopped")
41
+ } finally {
42
+ await daemon.shutdown()
43
+ await fs.rm(fixture.root, {force: true, recursive: true})
44
+ }
45
+ })
46
+
47
+ test("failed health check leaves the previous release active", async () => {
48
+ const fixture = await createFixture()
49
+ const daemon = await startDaemon(fixture.config)
50
+
51
+ try {
52
+ await daemon.deploy({releaseId: "good", releasePath: fixture.root, revision: "good"})
53
+
54
+ await assert.rejects(
55
+ () => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}),
56
+ /Health check failed/
57
+ )
58
+
59
+ assert.equal(await fetchText(daemon, "/release"), "good")
60
+ assert.equal(daemon.status().activeReleaseId, "good")
61
+ } finally {
62
+ await daemon.shutdown()
63
+ await fs.rm(fixture.root, {force: true, recursive: true})
64
+ }
65
+ })
66
+
67
+ test("singleton processes restart without overlap during deploy", async () => {
68
+ const fixture = await createFixture({includeSingleton: true})
69
+ const daemon = await startDaemon(fixture.config)
70
+
71
+ try {
72
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
73
+ await waitFor(async () => (await processEvents(fixture.singletonLogPath)).some((event) => event.event === "start" && event.releaseId === "v1"))
74
+
75
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
76
+ await waitFor(async () => {
77
+ const events = await processEvents(fixture.singletonLogPath)
78
+
79
+ return events.some((event) => event.event === "stop" && event.releaseId === "v1") &&
80
+ events.some((event) => event.event === "start" && event.releaseId === "v2")
81
+ })
82
+
83
+ const status = daemon.status()
84
+
85
+ assert.equal(status.singletons.length, 1)
86
+ assert.equal(status.singletons[0].process.state, "running")
87
+ } finally {
88
+ await daemon.shutdown()
89
+ await fs.rm(fixture.root, {force: true, recursive: true})
90
+ }
91
+ })
92
+
93
+ test("service processes start before releases and restart with the latest deploy template", async () => {
94
+ const fixture = await createFixture({includeService: true, webDependsOnService: true})
95
+ const daemon = await startDaemon(fixture.config)
96
+
97
+ try {
98
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
99
+ await waitFor(async () => (await processEvents(fixture.serviceLogPath)).some((event) => event.event === "start" && event.releaseId === "v1"))
100
+
101
+ const firstServiceStatus = daemon.status().services[0].process
102
+
103
+ assert.ok(firstServiceStatus.pid, "service should have a pid")
104
+ assert.match(firstServiceStatus.command, /v1/)
105
+
106
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
107
+
108
+ const secondServiceStatus = daemon.status().services[0].process
109
+
110
+ assert.equal(secondServiceStatus.pid, firstServiceStatus.pid)
111
+ assert.match(secondServiceStatus.command, /v2/)
112
+
113
+ process.kill(-Number(secondServiceStatus.pid), "SIGTERM")
114
+ await waitFor(async () => {
115
+ const events = await processEvents(fixture.serviceLogPath)
116
+
117
+ return events.some((event) => event.event === "start" && event.releaseId === "v2")
118
+ })
119
+ } finally {
120
+ await daemon.shutdown()
121
+ await fs.rm(fixture.root, {force: true, recursive: true})
122
+ }
123
+ })
124
+
125
+ test("control socket accepts deploy and status commands", async () => {
126
+ const fixture = await createFixture()
127
+ const daemon = await startDaemon(fixture.config)
128
+
129
+ try {
130
+ await sendControlCommand({
131
+ command: {
132
+ command: "deploy",
133
+ releaseId: "control-v1",
134
+ releasePath: fixture.root,
135
+ revision: "control-v1"
136
+ },
137
+ path: fixture.config.control.path
138
+ })
139
+
140
+ const status = await sendControlCommand({
141
+ command: {command: "status"},
142
+ path: fixture.config.control.path
143
+ })
144
+
145
+ assert.equal(status.activeReleaseId, "control-v1")
146
+ assert.equal(await fetchText(daemon, "/release"), "control-v1")
147
+ } finally {
148
+ await daemon.shutdown()
149
+ await fs.rm(fixture.root, {force: true, recursive: true})
150
+ }
151
+ })
152
+
153
+ test("starting a second daemon on a live control socket reports the running daemon", async () => {
154
+ const fixture = await createFixture()
155
+ const daemon = await startDaemon(fixture.config)
156
+
157
+ try {
158
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
159
+
160
+ const second = new RollbridgeDaemon({config: fixture.config, logger: () => {}})
161
+
162
+ await assert.rejects(
163
+ () => second.prepareControlSocketPath(),
164
+ (error) => {
165
+ assert.ok(error instanceof Error)
166
+ assert.match(error.message, /A Rollbridge daemon for application "rollbridge-test" is already running/)
167
+ assert.match(error.message, /active release: v1/)
168
+ assert.match(error.message, /rollbridge shutdown/)
169
+
170
+ return true
171
+ }
172
+ )
173
+
174
+ // The original daemon keeps its socket and still answers control commands.
175
+ const status = await sendControlCommand({command: {command: "status"}, path: fixture.config.control.path})
176
+ assert.equal(status.application, "rollbridge-test")
177
+ } finally {
178
+ await daemon.shutdown()
179
+ await fs.rm(fixture.root, {force: true, recursive: true})
180
+ }
181
+ })
182
+
183
+ test("a control socket held by a non-Rollbridge process reports a generic conflict", async () => {
184
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
185
+ const socketPath = path.join(root, "busy.sock")
186
+ const connections = /** @type {Set<import("node:net").Socket>} */ (new Set())
187
+ const stranger = net.createServer((socket) => {
188
+ // Accept connections but never answer, so the probe falls through to its timeout.
189
+ connections.add(socket)
190
+ socket.on("error", () => {})
191
+ socket.on("close", () => connections.delete(socket))
192
+ })
193
+
194
+ await new Promise((resolve) => stranger.listen(socketPath, () => resolve(undefined)))
195
+
196
+ const config = normalizeConfig({
197
+ application: "rollbridge-test",
198
+ control: {path: socketPath},
199
+ processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
200
+ proxy: {host: "127.0.0.1", port: 0}
201
+ })
202
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
203
+
204
+ try {
205
+ await assert.rejects(
206
+ () => daemon.prepareControlSocketPath(),
207
+ /The control socket .* is already in use by another process/
208
+ )
209
+ } finally {
210
+ for (const socket of connections) socket.destroy()
211
+ await new Promise((resolve) => stranger.close(() => resolve(undefined)))
212
+ await fs.rm(root, {force: true, recursive: true})
213
+ }
214
+ })
215
+
216
+ test("deploy can ensure the daemon before sending the release command", async () => {
217
+ const fixture = await createFixture()
218
+ const configPath = await writeConfigFile(fixture.config, fixture.root)
219
+ const logPath = path.join(fixture.root, "daemon.log")
220
+ const pidPath = path.join(fixture.root, "daemon.pid")
221
+
222
+ try {
223
+ await runCli([
224
+ "node",
225
+ binPath,
226
+ "deploy",
227
+ "--ensure-daemon",
228
+ "--config",
229
+ configPath,
230
+ "--release-path",
231
+ fixture.root,
232
+ "--release-id",
233
+ "ensured-v1",
234
+ "--daemon-log-path",
235
+ logPath,
236
+ "--daemon-pid-path",
237
+ pidPath
238
+ ])
239
+
240
+ const status = await sendControlCommand({
241
+ command: {command: "status"},
242
+ path: fixture.config.control.path
243
+ })
244
+
245
+ const proxy = /** @type {{port: number}} */ (status.proxy)
246
+
247
+ assert.equal(status.activeReleaseId, "ensured-v1")
248
+ assert.match(await fs.readFile(pidPath, "utf8"), /\d+/)
249
+ assert.equal(await fetchTextFromPort(proxy.port, "/release"), "ensured-v1")
250
+ } finally {
251
+ try {
252
+ await sendControlCommand({
253
+ command: {command: "shutdown"},
254
+ path: fixture.config.control.path
255
+ })
256
+ } catch (_error) {
257
+ // The daemon may have failed before it accepted commands.
258
+ }
259
+
260
+ await fs.rm(fixture.root, {force: true, recursive: true})
261
+ }
262
+ })
263
+
264
+ /**
265
+ * @param {{includeService?: boolean, includeSingleton?: boolean, webDependsOnService?: boolean}} [options] - Fixture options.
266
+ * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string}>} Fixture data.
267
+ */
268
+ async function createFixture(options = {}) {
269
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
270
+ const serviceLogPath = path.join(root, "service.log")
271
+ const singletonLogPath = path.join(root, "singleton.log")
272
+ /** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
273
+ const processes = []
274
+
275
+ if (options.includeService) {
276
+ processes.push({
277
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
278
+ env: {
279
+ ROLLBRIDGE_SERVICE_LOG: serviceLogPath
280
+ },
281
+ id: "beacon",
282
+ policy: "service",
283
+ port: {from: 0, to: 0},
284
+ restartDelayMs: 50
285
+ })
286
+ }
287
+
288
+ processes.push({
289
+ command: options.webDependsOnService
290
+ ? `${JSON.stringify(process.execPath)} ${JSON.stringify(dependentAppPath)}`
291
+ : `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
292
+ health: {
293
+ intervalMs: 50,
294
+ path: "/ping",
295
+ timeoutMs: 3000
296
+ },
297
+ id: "web",
298
+ policy: "proxied",
299
+ port: {from: 0, to: 0}
300
+ })
301
+
302
+ if (options.includeSingleton) {
303
+ processes.push({
304
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(singletonAppPath)}`,
305
+ env: {
306
+ ROLLBRIDGE_SINGLETON_LOG: singletonLogPath
307
+ },
308
+ id: "jobs-main",
309
+ policy: "singleton"
310
+ })
311
+ }
312
+
313
+ const config = normalizeConfig({
314
+ application: "rollbridge-test",
315
+ control: {
316
+ path: path.join(root, "rollbridge.sock")
317
+ },
318
+ processes,
319
+ proxy: {
320
+ drainTimeoutMs: 1000,
321
+ forceStopTimeoutMs: 500,
322
+ healthPath: "/ping",
323
+ healthTimeoutMs: 3000,
324
+ host: "127.0.0.1",
325
+ port: 0
326
+ }
327
+ })
328
+
329
+ return {config, root, serviceLogPath, singletonLogPath}
330
+ }
331
+
332
+ /**
333
+ * @param {import("../src/config.js").RollbridgeConfig} config - Config.
334
+ * @returns {Promise<RollbridgeDaemon>} Started daemon.
335
+ */
336
+ async function startDaemon(config) {
337
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
338
+
339
+ await daemon.start()
340
+
341
+ return daemon
342
+ }
343
+
344
+ /**
345
+ * @param {RollbridgeDaemon} daemon - Daemon.
346
+ * @param {string} pathName - Path.
347
+ * @returns {Promise<string>} Response text.
348
+ */
349
+ async function fetchText(daemon, pathName) {
350
+ return await fetchTextFromPort(Number(daemon.getProxyPort()), pathName)
351
+ }
352
+
353
+ /**
354
+ * @param {number} port - Port.
355
+ * @param {string} pathName - Path.
356
+ * @returns {Promise<string>} Response text.
357
+ */
358
+ async function fetchTextFromPort(port, pathName) {
359
+ const response = await fetch(`http://127.0.0.1:${port}${pathName}`)
360
+
361
+ assert.equal(response.status, 200)
362
+
363
+ return (await response.text()).trim()
364
+ }
365
+
366
+ /**
367
+ * @param {RollbridgeDaemon} daemon - Daemon.
368
+ * @returns {Promise<WebSocket>} Open WebSocket.
369
+ */
370
+ async function openWebSocket(daemon) {
371
+ const websocket = new WebSocket(`ws://127.0.0.1:${daemon.getProxyPort()}/socket`)
372
+
373
+ await new Promise((resolve, reject) => {
374
+ websocket.addEventListener("open", () => resolve(undefined), {once: true})
375
+ websocket.addEventListener("error", () => reject(new Error("WebSocket open failed")), {once: true})
376
+ })
377
+
378
+ return websocket
379
+ }
380
+
381
+ /**
382
+ * @param {RollbridgeDaemon} daemon - Daemon.
383
+ * @param {string} releaseId - Release id.
384
+ * @returns {import("../src/release-group.js").ReleaseStatus} Release status.
385
+ */
386
+ function statusRelease(daemon, releaseId) {
387
+ const status = daemon.status()
388
+ const release = status.releases.find((candidate) => candidate.releaseId === releaseId)
389
+
390
+ assert.ok(release, `Release ${releaseId} should be present`)
391
+
392
+ return release
393
+ }
394
+
395
+ /**
396
+ * @param {string} logPath - Log path.
397
+ * @returns {Promise<Array<{event: string, pid: number, releaseId: string}>>} Events.
398
+ */
399
+ async function processEvents(logPath) {
400
+ try {
401
+ const text = await fs.readFile(logPath, "utf8")
402
+
403
+ return text
404
+ .trim()
405
+ .split("\n")
406
+ .filter(Boolean)
407
+ .map((line) => JSON.parse(line))
408
+ } catch (error) {
409
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
410
+ return []
411
+ }
412
+
413
+ throw error
414
+ }
415
+ }
416
+
417
+ /**
418
+ * @param {import("../src/config.js").RollbridgeConfig} config - Config.
419
+ * @param {string} root - Fixture root.
420
+ * @returns {Promise<string>} Written config path.
421
+ */
422
+ async function writeConfigFile(config, root) {
423
+ const configPath = path.join(root, "rollbridge.js")
424
+
425
+ // CommonJS so the module loads from a temp dir (no package.json) on any supported Node version.
426
+ await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
427
+
428
+ return configPath
429
+ }
430
+
431
+ /**
432
+ * @param {() => Promise<boolean> | boolean} callback - Probe callback.
433
+ * @returns {Promise<void>} Resolves when callback returns true.
434
+ */
435
+ async function waitFor(callback) {
436
+ const deadline = Date.now() + 3000
437
+
438
+ while (Date.now() < deadline) {
439
+ if (await callback()) return
440
+ await new Promise((resolve) => setTimeout(resolve, 25))
441
+ }
442
+
443
+ throw new Error("Timed out waiting for condition")
444
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "allowSyntheticDefaultImports": true,
5
+ "checkJs": true,
6
+ "esModuleInterop": true,
7
+ "lib": ["ES2024", "DOM"],
8
+ "module": "NodeNext",
9
+ "moduleResolution": "NodeNext",
10
+ "noEmit": true,
11
+ "skipLibCheck": true,
12
+ "target": "ES2024",
13
+ "types": ["node"]
14
+ },
15
+ "include": ["bin/**/*", "scripts/**/*", "src/**/*", "test/**/*"]
16
+ }