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
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
|
@@ -8,8 +8,8 @@ import {spawn} from "node:child_process"
|
|
|
8
8
|
* @typedef {"starting" | "running" | "stopping" | "stopped" | "failed"} ManagedProcessState
|
|
9
9
|
* @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
|
|
10
10
|
* @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
|
|
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
|
|
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, restart: import("./config.js").RestartConfig, 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, restarts: number, startedAt: string | undefined, state: ManagedProcessState, uptimeMs: number | undefined}} ManagedProcessStatus
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export default class ManagedProcess extends EventEmitter {
|
|
@@ -21,11 +21,12 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
21
21
|
* @param {string} args.id - Process id.
|
|
22
22
|
* @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger callback.
|
|
23
23
|
* @param {number} args.outputLines - Recent stdout/stderr lines to retain and report.
|
|
24
|
+
* @param {import("./config.js").RestartConfig} [args.restart] - Restart policy (defaults to unlimited restarts with a constant delay).
|
|
24
25
|
* @param {number} args.restartDelayMs - Restart delay.
|
|
25
26
|
* @param {() => boolean} args.shouldRestart - Restart policy callback.
|
|
26
27
|
* @param {number} args.stopTimeoutMs - Stop timeout.
|
|
27
28
|
*/
|
|
28
|
-
constructor({command, cwd, env, id, logger, outputLines, restartDelayMs, shouldRestart, stopTimeoutMs}) {
|
|
29
|
+
constructor({command, cwd, env, id, logger, outputLines, restart = {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}, restartDelayMs, shouldRestart, stopTimeoutMs}) {
|
|
29
30
|
super()
|
|
30
31
|
|
|
31
32
|
this.command = command
|
|
@@ -34,11 +35,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
34
35
|
this.id = id
|
|
35
36
|
this.logger = logger
|
|
36
37
|
this.outputLines = outputLines
|
|
38
|
+
this.restart = restart
|
|
37
39
|
this.restartDelayMs = restartDelayMs
|
|
38
40
|
this.shouldRestart = shouldRestart
|
|
39
41
|
this.stopTimeoutMs = stopTimeoutMs
|
|
40
42
|
this.state = /** @type {ManagedProcessState} */ ("stopped")
|
|
41
43
|
this.logs = /** @type {ManagedProcessLog[]} */ ([])
|
|
44
|
+
this.restarts = 0
|
|
45
|
+
this.recentRestarts = /** @type {number[]} */ ([])
|
|
46
|
+
this.startedAtMs = /** @type {number | undefined} */ (undefined)
|
|
42
47
|
this.intentionalStop = false
|
|
43
48
|
this.restartTimer = undefined
|
|
44
49
|
this.child = undefined
|
|
@@ -77,6 +82,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
77
82
|
|
|
78
83
|
child.once("spawn", () => {
|
|
79
84
|
this.state = "running"
|
|
85
|
+
this.startedAtMs = Date.now()
|
|
80
86
|
this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
|
|
81
87
|
this.emit("started")
|
|
82
88
|
resolve(undefined)
|
|
@@ -103,6 +109,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
103
109
|
this.env = definition.env
|
|
104
110
|
this.logger = definition.logger
|
|
105
111
|
this.outputLines = definition.outputLines
|
|
112
|
+
this.restart = definition.restart
|
|
106
113
|
this.restartDelayMs = definition.restartDelayMs
|
|
107
114
|
this.shouldRestart = definition.shouldRestart
|
|
108
115
|
this.stopTimeoutMs = definition.stopTimeoutMs
|
|
@@ -143,13 +150,66 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
143
150
|
this.emit("exit", {code, signal})
|
|
144
151
|
|
|
145
152
|
if (!wasIntentional && this.shouldRestart()) {
|
|
146
|
-
this.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
this.scheduleRestart()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Schedules an automatic restart per the restart policy, or gives up once the policy's limit is reached.
|
|
159
|
+
* @returns {void}
|
|
160
|
+
*/
|
|
161
|
+
scheduleRestart() {
|
|
162
|
+
const {backoffFactor, maxRestarts, windowMs} = this.restart
|
|
163
|
+
|
|
164
|
+
// Fast path: unlimited restarts with a constant delay needs no per-restart bookkeeping.
|
|
165
|
+
// The delay is constant across attempts here (backoffFactor is 1), so restartDelayFor(0)
|
|
166
|
+
// gives the right value while still applying any maxDelayMs cap.
|
|
167
|
+
if (maxRestarts === undefined && backoffFactor === 1) {
|
|
168
|
+
this.queueRestart(this.restartDelayFor(0))
|
|
169
|
+
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const now = Date.now()
|
|
174
|
+
|
|
175
|
+
if (windowMs > 0) {
|
|
176
|
+
this.recentRestarts = this.recentRestarts.filter((time) => time > now - windowMs)
|
|
152
177
|
}
|
|
178
|
+
|
|
179
|
+
if (maxRestarts !== undefined && this.recentRestarts.length >= maxRestarts) {
|
|
180
|
+
this.logger("restart limit reached", {id: this.id, maxRestarts, windowMs})
|
|
181
|
+
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const delay = this.restartDelayFor(this.recentRestarts.length)
|
|
186
|
+
|
|
187
|
+
this.recentRestarts.push(now)
|
|
188
|
+
this.queueRestart(delay)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {number} attempt - Number of restarts already counted in the current window.
|
|
193
|
+
* @returns {number} Backed-off restart delay in milliseconds, capped by maxDelayMs when set.
|
|
194
|
+
*/
|
|
195
|
+
restartDelayFor(attempt) {
|
|
196
|
+
const backedOff = this.restartDelayMs * this.restart.backoffFactor ** attempt
|
|
197
|
+
|
|
198
|
+
return this.restart.maxDelayMs > 0 ? Math.min(backedOff, this.restart.maxDelayMs) : backedOff
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {number} delayMs - Delay before the restart attempt.
|
|
203
|
+
* @returns {void}
|
|
204
|
+
*/
|
|
205
|
+
queueRestart(delayMs) {
|
|
206
|
+
this.restartTimer = setTimeout(() => {
|
|
207
|
+
this.restartTimer = undefined
|
|
208
|
+
this.restarts += 1
|
|
209
|
+
this.start().catch((error) => {
|
|
210
|
+
this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
|
|
211
|
+
})
|
|
212
|
+
}, delayMs)
|
|
153
213
|
}
|
|
154
214
|
|
|
155
215
|
/**
|
|
@@ -229,7 +289,10 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
229
289
|
id: this.id,
|
|
230
290
|
logs: this.logs.slice(-this.outputLines),
|
|
231
291
|
pid: this.pid,
|
|
232
|
-
|
|
292
|
+
restarts: this.restarts,
|
|
293
|
+
startedAt: this.startedAtMs === undefined ? undefined : new Date(this.startedAtMs).toISOString(),
|
|
294
|
+
state: this.state,
|
|
295
|
+
uptimeMs: this.state === "running" && this.startedAtMs !== undefined ? Date.now() - this.startedAtMs : undefined
|
|
233
296
|
}
|
|
234
297
|
}
|
|
235
298
|
}
|
package/src/release-group.js
CHANGED
|
@@ -67,18 +67,54 @@ 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
|
+
* @param {string} id - Process id.
|
|
85
|
+
* @returns {ManagedProcess | undefined} This release's managed process with the given id, if present.
|
|
86
|
+
*/
|
|
87
|
+
getProcess(id) {
|
|
88
|
+
return this.processes.get(id)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Logs process diagnostics before failed startup cleanup stops and removes the release processes.
|
|
93
|
+
* @param {Error | string} error - Startup failure.
|
|
94
|
+
* @returns {void}
|
|
95
|
+
*/
|
|
96
|
+
logStartupFailure(error) {
|
|
97
|
+
this.logger("release startup failed", {
|
|
98
|
+
error: error instanceof Error ? error.message : error,
|
|
99
|
+
releaseId: this.releaseId
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
for (const processInstance of this.processes.values()) {
|
|
103
|
+
const status = processInstance.status()
|
|
104
|
+
|
|
105
|
+
this.logger("release startup process status", {
|
|
106
|
+
command: status.command,
|
|
107
|
+
exitCode: status.exitCode ?? null,
|
|
108
|
+
exitSignal: status.exitSignal ?? null,
|
|
109
|
+
logs: status.logs,
|
|
110
|
+
pid: status.pid ?? null,
|
|
111
|
+
processId: status.id,
|
|
112
|
+
releaseId: this.releaseId,
|
|
113
|
+
state: status.state
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
82
118
|
/**
|
|
83
119
|
* Starts companions before the proxied process so release-local dependencies are available before health checks.
|
|
84
120
|
* @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
|
|
@@ -112,7 +148,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
this.ports[processConfig.id] = await findAvailablePort({
|
|
115
|
-
host: this.config.proxy.
|
|
151
|
+
host: this.config.proxy.upstreamHost,
|
|
116
152
|
range: processConfig.port,
|
|
117
153
|
usedPorts
|
|
118
154
|
})
|
|
@@ -142,6 +178,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
142
178
|
id: processConfig.id,
|
|
143
179
|
logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
|
|
144
180
|
outputLines: processConfig.outputLines,
|
|
181
|
+
restart: processConfig.restart,
|
|
145
182
|
restartDelayMs: processConfig.restartDelayMs,
|
|
146
183
|
shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
|
|
147
184
|
stopTimeoutMs: processConfig.gracefulStopMs
|
|
@@ -186,7 +223,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
186
223
|
processId: processConfig.id,
|
|
187
224
|
proxy: {
|
|
188
225
|
host: this.config.proxy.host,
|
|
189
|
-
port: this.config.proxy.port
|
|
226
|
+
port: this.config.proxy.port,
|
|
227
|
+
upstreamHost: this.config.proxy.upstreamHost
|
|
190
228
|
},
|
|
191
229
|
releaseId: this.releaseId,
|
|
192
230
|
releasePath: this.releasePath,
|
|
@@ -211,7 +249,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
211
249
|
|
|
212
250
|
return {
|
|
213
251
|
process: processInstance,
|
|
214
|
-
target: `http://${this.config.proxy.
|
|
252
|
+
target: `http://${this.config.proxy.upstreamHost}:${port}`
|
|
215
253
|
}
|
|
216
254
|
}
|
|
217
255
|
|
|
@@ -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",
|
|
@@ -71,6 +86,46 @@ test("validateConfig defaults outputLines and accepts a positive override", () =
|
|
|
71
86
|
assert.equal(config.processes[1].outputLines, 5)
|
|
72
87
|
})
|
|
73
88
|
|
|
89
|
+
test("validateConfig defaults the restart policy, accepts overrides, and rejects bad values", () => {
|
|
90
|
+
/**
|
|
91
|
+
* @param {import("../src/json.js").JsonValue} restart - Restart policy under test, or undefined to omit it.
|
|
92
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
93
|
+
*/
|
|
94
|
+
const validateRestart = (restart) => validateConfig({
|
|
95
|
+
application: "demo",
|
|
96
|
+
control: {path: "/tmp/demo.sock"},
|
|
97
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}, restart}],
|
|
98
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const defaulted = validateRestart(undefined)
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(defaulted.issues, [])
|
|
104
|
+
assert.deepEqual(defaulted.config.processes[0].restart, {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
|
|
105
|
+
|
|
106
|
+
const custom = validateRestart({backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(custom.issues, [])
|
|
109
|
+
assert.deepEqual(custom.config.processes[0].restart, {backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
|
|
110
|
+
|
|
111
|
+
// maxRestarts: 0 disables automatic restarts.
|
|
112
|
+
const disabled = validateRestart({maxRestarts: 0})
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(disabled.issues, [])
|
|
115
|
+
assert.equal(disabled.config.processes[0].restart.maxRestarts, 0)
|
|
116
|
+
|
|
117
|
+
const invalid = validateRestart({backoffFactor: 0.5, maxDelayMs: -1, maxRestarts: -2, windowMs: -3})
|
|
118
|
+
const messages = invalid.issues.map((issue) => issue.message)
|
|
119
|
+
|
|
120
|
+
assert.ok(messages.includes("processes[0].restart.backoffFactor must be a number greater than or equal to 1"), JSON.stringify(messages))
|
|
121
|
+
assert.ok(messages.includes("processes[0].restart.maxRestarts must be a non-negative integer"), JSON.stringify(messages))
|
|
122
|
+
assert.ok(messages.includes("processes[0].restart.maxDelayMs must be a non-negative number"), JSON.stringify(messages))
|
|
123
|
+
assert.ok(messages.includes("processes[0].restart.windowMs must be a non-negative number"), JSON.stringify(messages))
|
|
124
|
+
|
|
125
|
+
// A fractional maxRestarts is rejected (it must be a whole number of restarts).
|
|
126
|
+
assert.ok(validateRestart({maxRestarts: 1.5}).issues.some((issue) => issue.message === "processes[0].restart.maxRestarts must be a non-negative integer"))
|
|
127
|
+
})
|
|
128
|
+
|
|
74
129
|
test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
|
|
75
130
|
const {issues} = validateConfig({
|
|
76
131
|
application: "demo",
|
|
@@ -117,6 +172,62 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
|
|
|
117
172
|
assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
|
|
118
173
|
})
|
|
119
174
|
|
|
175
|
+
test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
|
|
176
|
+
/**
|
|
177
|
+
* @param {import("../src/json.js").JsonValue} health - Health config under test, or undefined to omit it.
|
|
178
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
179
|
+
*/
|
|
180
|
+
const validateHealth = (health) => validateConfig({
|
|
181
|
+
application: "demo",
|
|
182
|
+
control: {path: "/tmp/demo.sock"},
|
|
183
|
+
processes: [{command: "run web", health, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
184
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const defaulted = validateHealth({path: "/ping"})
|
|
188
|
+
|
|
189
|
+
assert.deepEqual(defaulted.issues, [])
|
|
190
|
+
assert.equal(defaulted.config.processes[0].health?.startDelayMs, 0)
|
|
191
|
+
|
|
192
|
+
const custom = validateHealth({path: "/ping", startDelayMs: 2000})
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(custom.issues, [])
|
|
195
|
+
assert.equal(custom.config.processes[0].health?.startDelayMs, 2000)
|
|
196
|
+
|
|
197
|
+
const negative = validateHealth({path: "/ping", startDelayMs: -1})
|
|
198
|
+
|
|
199
|
+
assert.ok(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number"))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
|
|
203
|
+
/**
|
|
204
|
+
* @param {import("../src/json.js").JsonValue} releaseRetention - Retention config under test, or undefined.
|
|
205
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
206
|
+
*/
|
|
207
|
+
const validateRetention = (releaseRetention) => validateConfig({
|
|
208
|
+
application: "demo",
|
|
209
|
+
control: {path: "/tmp/demo.sock"},
|
|
210
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
211
|
+
proxy: {host: "127.0.0.1", port: 8182},
|
|
212
|
+
releaseRetention
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const defaulted = validateRetention(undefined)
|
|
216
|
+
|
|
217
|
+
assert.deepEqual(defaulted.issues, [])
|
|
218
|
+
assert.deepEqual(defaulted.config.releaseRetention, {keep: 10, maxAgeMs: 0})
|
|
219
|
+
|
|
220
|
+
const custom = validateRetention({keep: 3, maxAgeMs: 60000})
|
|
221
|
+
|
|
222
|
+
assert.deepEqual(custom.issues, [])
|
|
223
|
+
assert.deepEqual(custom.config.releaseRetention, {keep: 3, maxAgeMs: 60000})
|
|
224
|
+
|
|
225
|
+
const invalid = validateRetention({keep: -1, maxAgeMs: -5})
|
|
226
|
+
|
|
227
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer"))
|
|
228
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
|
|
229
|
+
})
|
|
230
|
+
|
|
120
231
|
test("normalizeConfig throws an aggregated error listing every issue", () => {
|
|
121
232
|
assert.throws(
|
|
122
233
|
() => normalizeConfig({
|
|
@@ -178,6 +289,40 @@ test("validate CLI command accepts a valid config without setting a failure exit
|
|
|
178
289
|
}
|
|
179
290
|
})
|
|
180
291
|
|
|
292
|
+
test("validate --json emits a machine-readable result", async () => {
|
|
293
|
+
const validPath = await writeConfig({
|
|
294
|
+
application: "demo",
|
|
295
|
+
control: {path: "/tmp/rollbridge-json-valid.sock"},
|
|
296
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
297
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
298
|
+
})
|
|
299
|
+
const invalidPath = await writeConfig({
|
|
300
|
+
application: "demo",
|
|
301
|
+
processes: [{command: "run web", id: "web", policy: "proxied"}],
|
|
302
|
+
proxy: {port: 8182}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
|
|
307
|
+
|
|
308
|
+
assert.equal(valid.valid, true)
|
|
309
|
+
assert.deepEqual(valid.issues, [])
|
|
310
|
+
assert.equal(valid.config.processes, 1)
|
|
311
|
+
assert.notEqual(process.exitCode, 1)
|
|
312
|
+
|
|
313
|
+
const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
|
|
314
|
+
|
|
315
|
+
assert.equal(invalid.valid, false)
|
|
316
|
+
assert.equal(invalid.config, null)
|
|
317
|
+
assert.ok(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message)))
|
|
318
|
+
assert.equal(process.exitCode, 1)
|
|
319
|
+
} finally {
|
|
320
|
+
process.exitCode = 0
|
|
321
|
+
await fs.rm(path.dirname(validPath), {force: true, recursive: true})
|
|
322
|
+
await fs.rm(path.dirname(invalidPath), {force: true, recursive: true})
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
181
326
|
/**
|
|
182
327
|
* @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config object.
|
|
183
328
|
* @returns {Promise<string>} Path to the written config module.
|