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.
- package/LICENSE +21 -0
- package/README.md +205 -5
- package/TODO.md +21 -18
- package/docs/cli.md +174 -0
- package/docs/config.md +148 -0
- package/docs/deploy-recipes.md +102 -0
- package/docs/nginx.md +104 -0
- package/docs/troubleshooting.md +102 -0
- package/docs/velocious.md +200 -0
- package/package.json +22 -2
- package/src/cli.js +168 -2
- package/src/config.js +146 -8
- package/src/daemon.js +138 -6
- package/src/doctor.js +114 -0
- package/src/health.js +4 -0
- package/src/managed-process.js +73 -10
- package/src/release-group.js +42 -4
- package/test/config-validation.test.js +145 -0
- package/test/doctor.test.js +228 -0
- package/test/fixtures/crasher.js +2 -0
- package/test/health.test.js +63 -0
- package/test/logs.test.js +99 -0
- package/test/managed-process.test.js +146 -0
- package/test/package-metadata.test.js +29 -0
- package/test/release-retention.test.js +107 -0
- package/test/rollbridge.test.js +249 -5
- package/scripts/release-patch.js +0 -83
|
@@ -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
|
+
}
|
package/test/rollbridge.test.js
CHANGED
|
@@ -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.
|
package/scripts/release-patch.js
DELETED
|
@@ -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"])
|