rollbridge 0.1.2 → 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.
- package/LICENSE +21 -0
- package/README.md +172 -5
- package/TODO.md +16 -13
- package/docs/cli.md +151 -0
- package/docs/config.md +128 -0
- package/docs/deploy-recipes.md +102 -0
- package/docs/troubleshooting.md +102 -0
- package/package.json +20 -1
- package/src/cli.js +141 -2
- package/src/config.js +73 -6
- package/src/daemon.js +61 -6
- package/src/doctor.js +114 -0
- package/src/health.js +4 -0
- package/src/managed-process.js +9 -2
- package/src/release-group.js +33 -4
- package/test/config-validation.test.js +105 -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 +60 -0
- package/test/package-metadata.test.js +29 -0
- package/test/release-retention.test.js +107 -0
- package/test/rollbridge.test.js +56 -5
package/src/doctor.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {constants as fsConstants} from "node:fs"
|
|
4
|
+
import fs from "node:fs/promises"
|
|
5
|
+
import net from "node:net"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import {inspectControlSocket} from "./daemon.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{detail: string, name: string, ok: boolean}} DoctorCheck
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs pre-flight environment checks for a validated config: control socket
|
|
15
|
+
* reachability, control-socket directory writability, and proxy port availability.
|
|
16
|
+
* @param {import("./config.js").RollbridgeConfig} config - Normalized config.
|
|
17
|
+
* @returns {Promise<DoctorCheck[]>} One check per probed aspect.
|
|
18
|
+
*/
|
|
19
|
+
export async function runEnvironmentChecks(config) {
|
|
20
|
+
/** @type {DoctorCheck[]} */
|
|
21
|
+
const checks = []
|
|
22
|
+
const socketInspection = await inspectControlSocketSafely(config.control.path)
|
|
23
|
+
|
|
24
|
+
checks.push(controlSocketCheck(config, socketInspection))
|
|
25
|
+
checks.push(await controlSocketDirectoryCheck(config))
|
|
26
|
+
checks.push(await proxyPortCheck(config))
|
|
27
|
+
|
|
28
|
+
return checks
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} socketPath - Control socket path.
|
|
33
|
+
* @returns {Promise<{alive: boolean, application?: string} | {error: string}>} Probe result, or the probe error.
|
|
34
|
+
*/
|
|
35
|
+
async function inspectControlSocketSafely(socketPath) {
|
|
36
|
+
try {
|
|
37
|
+
return await inspectControlSocket(socketPath)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {error: error instanceof Error ? error.message : String(error)}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {import("./config.js").RollbridgeConfig} config - Normalized config.
|
|
45
|
+
* @param {{alive: boolean, application?: string} | {error: string}} inspection - Control socket probe result.
|
|
46
|
+
* @returns {DoctorCheck} Control socket reachability check.
|
|
47
|
+
*/
|
|
48
|
+
function controlSocketCheck(config, inspection) {
|
|
49
|
+
const socketPath = config.control.path
|
|
50
|
+
|
|
51
|
+
if ("error" in inspection) {
|
|
52
|
+
return {detail: `could not probe ${socketPath}: ${inspection.error}`, name: "control socket", ok: false}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!inspection.alive) {
|
|
56
|
+
return {detail: `no daemon running; ${socketPath} is free to bind`, name: "control socket", ok: true}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (inspection.application === undefined) {
|
|
60
|
+
return {detail: `another process is already listening on ${socketPath}; the daemon would fail to bind it`, name: "control socket", ok: false}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {detail: `a Rollbridge daemon for "${inspection.application}" is already running on ${socketPath}; stop it before starting another`, name: "control socket", ok: false}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {import("./config.js").RollbridgeConfig} config - Normalized config.
|
|
68
|
+
* @returns {Promise<DoctorCheck>} Whether the control socket's directory is writable.
|
|
69
|
+
*/
|
|
70
|
+
async function controlSocketDirectoryCheck(config) {
|
|
71
|
+
const directory = path.dirname(path.resolve(config.control.path))
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Creating a Unix socket needs both write and search (execute) permission on the directory.
|
|
75
|
+
await fs.access(directory, fsConstants.W_OK | fsConstants.X_OK)
|
|
76
|
+
|
|
77
|
+
return {detail: `${directory} is writable`, name: "control socket directory", ok: true}
|
|
78
|
+
} catch {
|
|
79
|
+
return {detail: `${directory} is missing or not writable`, name: "control socket directory", ok: false}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {import("./config.js").RollbridgeConfig} config - Normalized config.
|
|
85
|
+
* @returns {Promise<DoctorCheck>} Whether the proxy port can be bound.
|
|
86
|
+
*/
|
|
87
|
+
async function proxyPortCheck(config) {
|
|
88
|
+
const address = `${config.proxy.host}:${config.proxy.port}`
|
|
89
|
+
const bind = await canBindPort(config.proxy.host, config.proxy.port)
|
|
90
|
+
|
|
91
|
+
if (bind.ok) {
|
|
92
|
+
return {detail: `${address} is available`, name: "proxy port", ok: true}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {detail: `${address} is unavailable (${bind.code})`, name: "proxy port", ok: false}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} host - Bind host.
|
|
100
|
+
* @param {number} port - Candidate port.
|
|
101
|
+
* @returns {Promise<{ok: true} | {code: string, ok: false}>} Whether the port can be bound.
|
|
102
|
+
*/
|
|
103
|
+
async function canBindPort(host, port) {
|
|
104
|
+
return await new Promise((resolve) => {
|
|
105
|
+
const server = net.createServer()
|
|
106
|
+
|
|
107
|
+
server.once("error", (error) => {
|
|
108
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "EUNKNOWN"
|
|
109
|
+
|
|
110
|
+
resolve({code, ok: false})
|
|
111
|
+
})
|
|
112
|
+
server.listen(port, host, () => server.close(() => resolve({ok: true})))
|
|
113
|
+
})
|
|
114
|
+
}
|
package/src/health.js
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* @returns {Promise<void>} Resolves when healthy.
|
|
10
10
|
*/
|
|
11
11
|
export async function waitForHealth({health, host, port}) {
|
|
12
|
+
if (health.startDelayMs > 0) {
|
|
13
|
+
await new Promise((resolve) => setTimeout(resolve, health.startDelayMs))
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
const deadline = Date.now() + health.timeoutMs
|
|
13
17
|
const url = `http://${host}:${port}${health.path}`
|
|
14
18
|
let lastError = "no attempts"
|
package/src/managed-process.js
CHANGED
|
@@ -9,7 +9,7 @@ import {spawn} from "node:child_process"
|
|
|
9
9
|
* @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
|
|
10
10
|
* @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
|
|
11
11
|
* @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, outputLines: number, restartDelayMs: number, shouldRestart: () => boolean, stopTimeoutMs: number}} ManagedProcessDefinition
|
|
12
|
-
* @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, state: ManagedProcessState}} ManagedProcessStatus
|
|
12
|
+
* @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, restarts: number, startedAt: string | undefined, state: ManagedProcessState, uptimeMs: number | undefined}} ManagedProcessStatus
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export default class ManagedProcess extends EventEmitter {
|
|
@@ -39,6 +39,8 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
39
39
|
this.stopTimeoutMs = stopTimeoutMs
|
|
40
40
|
this.state = /** @type {ManagedProcessState} */ ("stopped")
|
|
41
41
|
this.logs = /** @type {ManagedProcessLog[]} */ ([])
|
|
42
|
+
this.restarts = 0
|
|
43
|
+
this.startedAtMs = /** @type {number | undefined} */ (undefined)
|
|
42
44
|
this.intentionalStop = false
|
|
43
45
|
this.restartTimer = undefined
|
|
44
46
|
this.child = undefined
|
|
@@ -77,6 +79,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
77
79
|
|
|
78
80
|
child.once("spawn", () => {
|
|
79
81
|
this.state = "running"
|
|
82
|
+
this.startedAtMs = Date.now()
|
|
80
83
|
this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
|
|
81
84
|
this.emit("started")
|
|
82
85
|
resolve(undefined)
|
|
@@ -145,6 +148,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
145
148
|
if (!wasIntentional && this.shouldRestart()) {
|
|
146
149
|
this.restartTimer = setTimeout(() => {
|
|
147
150
|
this.restartTimer = undefined
|
|
151
|
+
this.restarts += 1
|
|
148
152
|
this.start().catch((error) => {
|
|
149
153
|
this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
|
|
150
154
|
})
|
|
@@ -229,7 +233,10 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
229
233
|
id: this.id,
|
|
230
234
|
logs: this.logs.slice(-this.outputLines),
|
|
231
235
|
pid: this.pid,
|
|
232
|
-
|
|
236
|
+
restarts: this.restarts,
|
|
237
|
+
startedAt: this.startedAtMs === undefined ? undefined : new Date(this.startedAtMs).toISOString(),
|
|
238
|
+
state: this.state,
|
|
239
|
+
uptimeMs: this.state === "running" && this.startedAtMs !== undefined ? Date.now() - this.startedAtMs : undefined
|
|
233
240
|
}
|
|
234
241
|
}
|
|
235
242
|
}
|
package/src/release-group.js
CHANGED
|
@@ -67,18 +67,46 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
67
67
|
if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
|
|
68
68
|
await waitForHealth({
|
|
69
69
|
health: processConfig.health,
|
|
70
|
-
host: this.config.proxy.
|
|
70
|
+
host: this.config.proxy.upstreamHost,
|
|
71
71
|
port: this.ports[processConfig.id]
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
} catch (error) {
|
|
76
76
|
this.state = "failed"
|
|
77
|
+
this.logStartupFailure(error instanceof Error ? error : String(error))
|
|
77
78
|
await this.stop()
|
|
78
79
|
throw error
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Logs process diagnostics before failed startup cleanup stops and removes the release processes.
|
|
85
|
+
* @param {Error | string} error - Startup failure.
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
logStartupFailure(error) {
|
|
89
|
+
this.logger("release startup failed", {
|
|
90
|
+
error: error instanceof Error ? error.message : error,
|
|
91
|
+
releaseId: this.releaseId
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
for (const processInstance of this.processes.values()) {
|
|
95
|
+
const status = processInstance.status()
|
|
96
|
+
|
|
97
|
+
this.logger("release startup process status", {
|
|
98
|
+
command: status.command,
|
|
99
|
+
exitCode: status.exitCode ?? null,
|
|
100
|
+
exitSignal: status.exitSignal ?? null,
|
|
101
|
+
logs: status.logs,
|
|
102
|
+
pid: status.pid ?? null,
|
|
103
|
+
processId: status.id,
|
|
104
|
+
releaseId: this.releaseId,
|
|
105
|
+
state: status.state
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/**
|
|
83
111
|
* Starts companions before the proxied process so release-local dependencies are available before health checks.
|
|
84
112
|
* @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
|
|
@@ -112,7 +140,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
this.ports[processConfig.id] = await findAvailablePort({
|
|
115
|
-
host: this.config.proxy.
|
|
143
|
+
host: this.config.proxy.upstreamHost,
|
|
116
144
|
range: processConfig.port,
|
|
117
145
|
usedPorts
|
|
118
146
|
})
|
|
@@ -186,7 +214,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
186
214
|
processId: processConfig.id,
|
|
187
215
|
proxy: {
|
|
188
216
|
host: this.config.proxy.host,
|
|
189
|
-
port: this.config.proxy.port
|
|
217
|
+
port: this.config.proxy.port,
|
|
218
|
+
upstreamHost: this.config.proxy.upstreamHost
|
|
190
219
|
},
|
|
191
220
|
releaseId: this.releaseId,
|
|
192
221
|
releasePath: this.releasePath,
|
|
@@ -211,7 +240,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
211
240
|
|
|
212
241
|
return {
|
|
213
242
|
process: processInstance,
|
|
214
|
-
target: `http://${this.config.proxy.
|
|
243
|
+
target: `http://${this.config.proxy.upstreamHost}:${port}`
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
246
|
|
|
@@ -55,6 +55,21 @@ test("validateConfig returns a normalized config and no issues for a valid confi
|
|
|
55
55
|
assert.equal(config.proxy.port, 8182)
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
|
|
59
|
+
const {config, issues} = validateConfig({
|
|
60
|
+
application: "demo",
|
|
61
|
+
control: {path: "/tmp/demo.sock"},
|
|
62
|
+
processes: [
|
|
63
|
+
{command: "run web", health: {path: "/ping"}, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}
|
|
64
|
+
],
|
|
65
|
+
proxy: {host: "0.0.0.0", port: 8182}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(issues, [])
|
|
69
|
+
assert.equal(config.proxy.host, "0.0.0.0")
|
|
70
|
+
assert.equal(config.proxy.upstreamHost, "127.0.0.1")
|
|
71
|
+
})
|
|
72
|
+
|
|
58
73
|
test("validateConfig defaults outputLines and accepts a positive override", () => {
|
|
59
74
|
const {config, issues} = validateConfig({
|
|
60
75
|
application: "demo",
|
|
@@ -117,6 +132,62 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
|
|
|
117
132
|
assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
|
|
118
133
|
})
|
|
119
134
|
|
|
135
|
+
test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
|
|
136
|
+
/**
|
|
137
|
+
* @param {import("../src/json.js").JsonValue} health - Health config under test, or undefined to omit it.
|
|
138
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
139
|
+
*/
|
|
140
|
+
const validateHealth = (health) => validateConfig({
|
|
141
|
+
application: "demo",
|
|
142
|
+
control: {path: "/tmp/demo.sock"},
|
|
143
|
+
processes: [{command: "run web", health, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
144
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const defaulted = validateHealth({path: "/ping"})
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(defaulted.issues, [])
|
|
150
|
+
assert.equal(defaulted.config.processes[0].health?.startDelayMs, 0)
|
|
151
|
+
|
|
152
|
+
const custom = validateHealth({path: "/ping", startDelayMs: 2000})
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(custom.issues, [])
|
|
155
|
+
assert.equal(custom.config.processes[0].health?.startDelayMs, 2000)
|
|
156
|
+
|
|
157
|
+
const negative = validateHealth({path: "/ping", startDelayMs: -1})
|
|
158
|
+
|
|
159
|
+
assert.ok(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number"))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
|
|
163
|
+
/**
|
|
164
|
+
* @param {import("../src/json.js").JsonValue} releaseRetention - Retention config under test, or undefined.
|
|
165
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
166
|
+
*/
|
|
167
|
+
const validateRetention = (releaseRetention) => validateConfig({
|
|
168
|
+
application: "demo",
|
|
169
|
+
control: {path: "/tmp/demo.sock"},
|
|
170
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
171
|
+
proxy: {host: "127.0.0.1", port: 8182},
|
|
172
|
+
releaseRetention
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const defaulted = validateRetention(undefined)
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(defaulted.issues, [])
|
|
178
|
+
assert.deepEqual(defaulted.config.releaseRetention, {keep: 10, maxAgeMs: 0})
|
|
179
|
+
|
|
180
|
+
const custom = validateRetention({keep: 3, maxAgeMs: 60000})
|
|
181
|
+
|
|
182
|
+
assert.deepEqual(custom.issues, [])
|
|
183
|
+
assert.deepEqual(custom.config.releaseRetention, {keep: 3, maxAgeMs: 60000})
|
|
184
|
+
|
|
185
|
+
const invalid = validateRetention({keep: -1, maxAgeMs: -5})
|
|
186
|
+
|
|
187
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer"))
|
|
188
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
|
|
189
|
+
})
|
|
190
|
+
|
|
120
191
|
test("normalizeConfig throws an aggregated error listing every issue", () => {
|
|
121
192
|
assert.throws(
|
|
122
193
|
() => normalizeConfig({
|
|
@@ -178,6 +249,40 @@ test("validate CLI command accepts a valid config without setting a failure exit
|
|
|
178
249
|
}
|
|
179
250
|
})
|
|
180
251
|
|
|
252
|
+
test("validate --json emits a machine-readable result", async () => {
|
|
253
|
+
const validPath = await writeConfig({
|
|
254
|
+
application: "demo",
|
|
255
|
+
control: {path: "/tmp/rollbridge-json-valid.sock"},
|
|
256
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
257
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
258
|
+
})
|
|
259
|
+
const invalidPath = await writeConfig({
|
|
260
|
+
application: "demo",
|
|
261
|
+
processes: [{command: "run web", id: "web", policy: "proxied"}],
|
|
262
|
+
proxy: {port: 8182}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
|
|
267
|
+
|
|
268
|
+
assert.equal(valid.valid, true)
|
|
269
|
+
assert.deepEqual(valid.issues, [])
|
|
270
|
+
assert.equal(valid.config.processes, 1)
|
|
271
|
+
assert.notEqual(process.exitCode, 1)
|
|
272
|
+
|
|
273
|
+
const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
|
|
274
|
+
|
|
275
|
+
assert.equal(invalid.valid, false)
|
|
276
|
+
assert.equal(invalid.config, null)
|
|
277
|
+
assert.ok(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message)))
|
|
278
|
+
assert.equal(process.exitCode, 1)
|
|
279
|
+
} finally {
|
|
280
|
+
process.exitCode = 0
|
|
281
|
+
await fs.rm(path.dirname(validPath), {force: true, recursive: true})
|
|
282
|
+
await fs.rm(path.dirname(invalidPath), {force: true, recursive: true})
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
181
286
|
/**
|
|
182
287
|
* @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config object.
|
|
183
288
|
* @returns {Promise<string>} Path to the written config module.
|
|
@@ -0,0 +1,228 @@
|
|
|
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 RollbridgeDaemon from "../src/daemon.js"
|
|
10
|
+
import {normalizeConfig} from "../src/config.js"
|
|
11
|
+
import {runEnvironmentChecks} from "../src/doctor.js"
|
|
12
|
+
import {runCli} from "../src/cli.js"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} args - Options.
|
|
16
|
+
* @param {string} args.controlPath - Control socket path.
|
|
17
|
+
* @param {number} args.proxyPort - Proxy port.
|
|
18
|
+
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
19
|
+
*/
|
|
20
|
+
function buildConfig({controlPath, proxyPort}) {
|
|
21
|
+
return normalizeConfig({
|
|
22
|
+
application: "doctor-test",
|
|
23
|
+
control: {path: controlPath},
|
|
24
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
25
|
+
proxy: {host: "127.0.0.1", port: proxyPort}
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @returns {Promise<number>} A port that was free when probed.
|
|
31
|
+
*/
|
|
32
|
+
async function freePort() {
|
|
33
|
+
return await new Promise((resolve) => {
|
|
34
|
+
const server = net.createServer()
|
|
35
|
+
|
|
36
|
+
server.listen(0, "127.0.0.1", () => {
|
|
37
|
+
const address = server.address()
|
|
38
|
+
const port = address && typeof address === "object" ? address.port : 0
|
|
39
|
+
|
|
40
|
+
server.close(() => resolve(port))
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {Promise<{port: number, server: import("node:net").Server}>} An occupied port and its server.
|
|
47
|
+
*/
|
|
48
|
+
async function occupyPort() {
|
|
49
|
+
const server = net.createServer()
|
|
50
|
+
const port = await new Promise((resolve) => {
|
|
51
|
+
server.listen(0, "127.0.0.1", () => {
|
|
52
|
+
const address = server.address()
|
|
53
|
+
|
|
54
|
+
resolve(address && typeof address === "object" ? address.port : 0)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return {port, server}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {DoctorCheck[]} checks - Checks.
|
|
63
|
+
* @param {string} name - Check name.
|
|
64
|
+
* @returns {DoctorCheck} The matching check.
|
|
65
|
+
* @typedef {import("../src/doctor.js").DoctorCheck} DoctorCheck
|
|
66
|
+
*/
|
|
67
|
+
function checkNamed(checks, name) {
|
|
68
|
+
const check = checks.find((candidate) => candidate.name === name)
|
|
69
|
+
|
|
70
|
+
assert.ok(check, `expected a "${name}" check`)
|
|
71
|
+
|
|
72
|
+
return check
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("runEnvironmentChecks passes when no daemon runs, the port is free, and the directory is writable", async () => {
|
|
76
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()}))
|
|
80
|
+
|
|
81
|
+
assert.equal(checkNamed(checks, "control socket").ok, true)
|
|
82
|
+
assert.equal(checkNamed(checks, "control socket directory").ok, true)
|
|
83
|
+
assert.equal(checkNamed(checks, "proxy port").ok, true)
|
|
84
|
+
} finally {
|
|
85
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("runEnvironmentChecks reports an unavailable proxy port", async () => {
|
|
90
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
91
|
+
const {port, server} = await occupyPort()
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: port}))
|
|
95
|
+
const proxyCheck = checkNamed(checks, "proxy port")
|
|
96
|
+
|
|
97
|
+
assert.equal(proxyCheck.ok, false)
|
|
98
|
+
assert.match(proxyCheck.detail, /unavailable/)
|
|
99
|
+
} finally {
|
|
100
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
101
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("runEnvironmentChecks reports a missing control socket directory", async () => {
|
|
106
|
+
const checks = await runEnvironmentChecks(buildConfig({controlPath: "/rollbridge-doctor-missing-dir/rollbridge.sock", proxyPort: await freePort()}))
|
|
107
|
+
const directoryCheck = checkNamed(checks, "control socket directory")
|
|
108
|
+
|
|
109
|
+
assert.equal(directoryCheck.ok, false)
|
|
110
|
+
assert.match(directoryCheck.detail, /missing or not writable/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("runEnvironmentChecks fails when a Rollbridge daemon already holds the socket and port", async () => {
|
|
114
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
115
|
+
const config = buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()})
|
|
116
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
117
|
+
|
|
118
|
+
await daemon.start()
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const checks = await runEnvironmentChecks(config)
|
|
122
|
+
const socketCheck = checkNamed(checks, "control socket")
|
|
123
|
+
|
|
124
|
+
// A daemon already running means `rollbridge daemon` would fail to bind, so doctor must fail too.
|
|
125
|
+
assert.equal(socketCheck.ok, false)
|
|
126
|
+
assert.match(socketCheck.detail, /a Rollbridge daemon for "doctor-test" is already running/)
|
|
127
|
+
assert.equal(checkNamed(checks, "proxy port").ok, false)
|
|
128
|
+
} finally {
|
|
129
|
+
await daemon.shutdown()
|
|
130
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Runs the CLI while capturing console output.
|
|
136
|
+
* @param {string[]} argv - Process argv.
|
|
137
|
+
* @returns {Promise<string>} Captured stdout and stderr lines.
|
|
138
|
+
*/
|
|
139
|
+
async function captureCli(argv) {
|
|
140
|
+
const originalLog = console.log
|
|
141
|
+
const originalError = console.error
|
|
142
|
+
/** @type {string[]} */
|
|
143
|
+
const lines = []
|
|
144
|
+
const collect = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
|
|
145
|
+
|
|
146
|
+
console.log = collect
|
|
147
|
+
console.error = collect
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await runCli(argv)
|
|
151
|
+
} finally {
|
|
152
|
+
console.log = originalLog
|
|
153
|
+
console.error = originalError
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines.join("\n")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test("doctor CLI passes for a valid, bindable config", async () => {
|
|
160
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
161
|
+
const rawConfig = {
|
|
162
|
+
application: "doctor-cli-test",
|
|
163
|
+
control: {path: path.join(root, "rollbridge.sock")},
|
|
164
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
165
|
+
proxy: {host: "127.0.0.1", port: await freePort()}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await fs.writeFile(path.join(root, "rollbridge.js"), `module.exports = ${JSON.stringify(rawConfig)}\n`)
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
|
|
172
|
+
|
|
173
|
+
assert.match(output, /All checks passed\./)
|
|
174
|
+
assert.notEqual(process.exitCode, 1)
|
|
175
|
+
} finally {
|
|
176
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("doctor CLI fails and exits non-zero for an invalid config", async () => {
|
|
181
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
182
|
+
|
|
183
|
+
await fs.writeFile(path.join(root, "rollbridge.js"), "module.exports = {application: \"x\", proxy: {port: 8182}, processes: []}\n")
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const output = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js")])
|
|
187
|
+
|
|
188
|
+
assert.equal(process.exitCode, 1)
|
|
189
|
+
assert.match(output, /✗ config:/)
|
|
190
|
+
} finally {
|
|
191
|
+
process.exitCode = 0
|
|
192
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("doctor --json emits structured checks", async () => {
|
|
197
|
+
const okRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
198
|
+
const okConfig = {
|
|
199
|
+
application: "doctor-json-test",
|
|
200
|
+
control: {path: path.join(okRoot, "rollbridge.sock")},
|
|
201
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
202
|
+
proxy: {host: "127.0.0.1", port: await freePort()}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await fs.writeFile(path.join(okRoot, "rollbridge.js"), `module.exports = ${JSON.stringify(okConfig)}\n`)
|
|
206
|
+
|
|
207
|
+
const badRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
208
|
+
|
|
209
|
+
await fs.writeFile(path.join(badRoot, "rollbridge.js"), "module.exports = {application: \"x\", proxy: {port: 8182}, processes: []}\n")
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const passing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(okRoot, "rollbridge.js")]))
|
|
213
|
+
|
|
214
|
+
assert.equal(passing.ok, true)
|
|
215
|
+
assert.ok(passing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "proxy port" && check.ok === true))
|
|
216
|
+
assert.notEqual(process.exitCode, 1)
|
|
217
|
+
|
|
218
|
+
const failing = JSON.parse(await captureCli(["node", "rollbridge", "doctor", "--json", "-c", path.join(badRoot, "rollbridge.js")]))
|
|
219
|
+
|
|
220
|
+
assert.equal(failing.ok, false)
|
|
221
|
+
assert.ok(failing.checks.some((/** @type {{name: string, ok: boolean}} */ check) => check.name === "config" && check.ok === false))
|
|
222
|
+
assert.equal(process.exitCode, 1)
|
|
223
|
+
} finally {
|
|
224
|
+
process.exitCode = 0
|
|
225
|
+
await fs.rm(okRoot, {force: true, recursive: true})
|
|
226
|
+
await fs.rm(badRoot, {force: true, recursive: true})
|
|
227
|
+
}
|
|
228
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import http from "node:http"
|
|
5
|
+
import test from "node:test"
|
|
6
|
+
import {waitForHealth} from "../src/health.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starts a health server that records when it first receives a probe.
|
|
10
|
+
* @returns {Promise<{firstProbeDelay: () => number, port: number, close: () => Promise<void>}>} Server handle.
|
|
11
|
+
*/
|
|
12
|
+
async function startHealthServer() {
|
|
13
|
+
const start = Date.now()
|
|
14
|
+
let firstProbeAt = 0
|
|
15
|
+
const server = http.createServer((request, response) => {
|
|
16
|
+
if (firstProbeAt === 0) firstProbeAt = Date.now()
|
|
17
|
+
|
|
18
|
+
response.writeHead(200, {"Content-Type": "text/plain"})
|
|
19
|
+
response.end("ok")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(undefined)))
|
|
23
|
+
|
|
24
|
+
const address = server.address()
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
close: () => new Promise((resolve) => server.close(() => resolve(undefined))),
|
|
28
|
+
firstProbeDelay: () => firstProbeAt - start,
|
|
29
|
+
port: address && typeof address === "object" ? address.port : 0
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("waitForHealth delays the first probe by startDelayMs", async () => {
|
|
34
|
+
const server = await startHealthServer()
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await waitForHealth({
|
|
38
|
+
health: {intervalMs: 25, path: "/ping", startDelayMs: 200, timeoutMs: 2000},
|
|
39
|
+
host: "127.0.0.1",
|
|
40
|
+
port: server.port
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
assert.ok(server.firstProbeDelay() >= 180, `expected first probe to be delayed ~200ms, was ${server.firstProbeDelay()}ms`)
|
|
44
|
+
} finally {
|
|
45
|
+
await server.close()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("waitForHealth probes immediately when startDelayMs is 0", async () => {
|
|
50
|
+
const server = await startHealthServer()
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await waitForHealth({
|
|
54
|
+
health: {intervalMs: 25, path: "/ping", startDelayMs: 0, timeoutMs: 2000},
|
|
55
|
+
host: "127.0.0.1",
|
|
56
|
+
port: server.port
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
assert.ok(server.firstProbeDelay() < 150, `expected an immediate first probe, was ${server.firstProbeDelay()}ms`)
|
|
60
|
+
} finally {
|
|
61
|
+
await server.close()
|
|
62
|
+
}
|
|
63
|
+
})
|