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/package.json
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rollbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deploy",
|
|
7
|
+
"zero-downtime",
|
|
8
|
+
"process-supervisor",
|
|
9
|
+
"reverse-proxy",
|
|
10
|
+
"websocket",
|
|
11
|
+
"rollbridge",
|
|
12
|
+
"velocious"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/kaspernj/rollbridge#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/kaspernj/rollbridge/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "kaspernj <kasper@diestoeckels.de>",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/kaspernj/rollbridge.git"
|
|
23
|
+
},
|
|
5
24
|
"type": "module",
|
|
6
25
|
"bin": {
|
|
7
26
|
"rollbridge": "./bin/rollbridge"
|
|
@@ -9,7 +28,7 @@
|
|
|
9
28
|
"scripts": {
|
|
10
29
|
"all-checks": "npm run typecheck && npm run lint && npm test",
|
|
11
30
|
"lint": "eslint",
|
|
12
|
-
"release:patch": "
|
|
31
|
+
"release:patch": "release-patch",
|
|
13
32
|
"test": "node --test test/*.test.js",
|
|
14
33
|
"typecheck": "tsc --noEmit"
|
|
15
34
|
},
|
|
@@ -27,6 +46,7 @@
|
|
|
27
46
|
"eslint": "^10.4.0",
|
|
28
47
|
"eslint-plugin-jsdoc": "^62.9.0",
|
|
29
48
|
"globals": "^17.6.0",
|
|
49
|
+
"release-patch": "^1.0.0",
|
|
30
50
|
"typescript": "^6.0.3"
|
|
31
51
|
}
|
|
32
52
|
}
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import {spawn} from "node:child_process"
|
|
|
7
7
|
import {Command} from "commander"
|
|
8
8
|
import RollbridgeDaemon from "./daemon.js"
|
|
9
9
|
import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
|
|
10
|
+
import {runEnvironmentChecks} from "./doctor.js"
|
|
10
11
|
import {sendControlCommand} from "./control-client.js"
|
|
11
12
|
|
|
12
13
|
const DEFAULT_DAEMON_START_TIMEOUT_MS = 10000
|
|
@@ -135,6 +136,33 @@ export async function runCli(argv) {
|
|
|
135
136
|
console.log(JSON.stringify(response, null, 2))
|
|
136
137
|
})
|
|
137
138
|
|
|
139
|
+
program
|
|
140
|
+
.command("restart")
|
|
141
|
+
.description("Restart running non-proxied processes (by id, by policy, or all).")
|
|
142
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
143
|
+
.option("--process <id>", "Restart only the process with this id")
|
|
144
|
+
.option("--policy <policy>", "Restart only processes with this policy (companion, singleton, or service)")
|
|
145
|
+
.action(async (options) => {
|
|
146
|
+
if (options.policy !== undefined && !["companion", "service", "singleton"].includes(options.policy)) {
|
|
147
|
+
console.error("--policy must be one of: companion, singleton, service.")
|
|
148
|
+
process.exitCode = 1
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const configPath = await resolveConfigPath(options.config)
|
|
153
|
+
const config = await loadConfig(configPath)
|
|
154
|
+
const response = await sendControlCommand({
|
|
155
|
+
command: {
|
|
156
|
+
command: "restart",
|
|
157
|
+
policy: options.policy,
|
|
158
|
+
processId: options.process
|
|
159
|
+
},
|
|
160
|
+
path: config.control.path
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
console.log(JSON.stringify(response, null, 2))
|
|
164
|
+
})
|
|
165
|
+
|
|
138
166
|
program
|
|
139
167
|
.command("shutdown")
|
|
140
168
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
@@ -153,20 +181,33 @@ export async function runCli(argv) {
|
|
|
153
181
|
.command("validate")
|
|
154
182
|
.description("Parse the config and report all errors without starting the daemon.")
|
|
155
183
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
184
|
+
.option("--json", "Output machine-readable JSON")
|
|
156
185
|
.action(async (options) => {
|
|
157
186
|
let configPath
|
|
158
187
|
|
|
159
188
|
try {
|
|
160
189
|
configPath = await resolveConfigPath(options.config)
|
|
161
190
|
} catch (error) {
|
|
162
|
-
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
192
|
+
|
|
193
|
+
if (options.json) console.log(JSON.stringify({config: null, issues: [{fix: "Pass --config or add a rollbridge.js.", message}], path: null, valid: false}, null, 2))
|
|
194
|
+
else console.error(message)
|
|
163
195
|
process.exitCode = 1
|
|
164
196
|
return
|
|
165
197
|
}
|
|
166
198
|
|
|
167
199
|
const {config, issues} = await validateConfigFile(configPath)
|
|
200
|
+
const valid = issues.length === 0
|
|
201
|
+
|
|
202
|
+
if (options.json) {
|
|
203
|
+
const summary = valid ? {application: config.application, processes: config.processes.length, proxy: {host: config.proxy.host, port: config.proxy.port}} : null
|
|
168
204
|
|
|
169
|
-
|
|
205
|
+
console.log(JSON.stringify({config: summary, issues, path: configPath, valid}, null, 2))
|
|
206
|
+
if (!valid) process.exitCode = 1
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (valid) {
|
|
170
211
|
const processCount = config.processes.length
|
|
171
212
|
|
|
172
213
|
console.log(`${configPath} is valid: ${processCount} ${processCount === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}.`)
|
|
@@ -183,9 +224,134 @@ export async function runCli(argv) {
|
|
|
183
224
|
process.exitCode = 1
|
|
184
225
|
})
|
|
185
226
|
|
|
227
|
+
program
|
|
228
|
+
.command("doctor")
|
|
229
|
+
.description("Check the environment before starting the daemon: config, control socket, and proxy port.")
|
|
230
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
231
|
+
.option("--json", "Output machine-readable JSON")
|
|
232
|
+
.action(async (options) => {
|
|
233
|
+
let configPath
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
configPath = await resolveConfigPath(options.config)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
239
|
+
|
|
240
|
+
if (options.json) console.log(JSON.stringify({checks: [{detail: message, name: "config", ok: false}], ok: false}, null, 2))
|
|
241
|
+
else console.error(message)
|
|
242
|
+
process.exitCode = 1
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const {config, issues} = await validateConfigFile(configPath)
|
|
247
|
+
/** @type {import("./doctor.js").DoctorCheck[]} */
|
|
248
|
+
const checks = []
|
|
249
|
+
|
|
250
|
+
if (issues.length > 0) {
|
|
251
|
+
checks.push({detail: `${issues.length} ${issues.length === 1 ? "issue" : "issues"} — run "rollbridge validate" for details`, name: "config", ok: false})
|
|
252
|
+
} else {
|
|
253
|
+
checks.push({detail: `valid: ${config.processes.length} ${config.processes.length === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}`, name: "config", ok: true})
|
|
254
|
+
checks.push(...await runEnvironmentChecks(config))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const failed = checks.filter((check) => !check.ok).length
|
|
258
|
+
|
|
259
|
+
if (options.json) {
|
|
260
|
+
console.log(JSON.stringify({checks, ok: failed === 0}, null, 2))
|
|
261
|
+
} else {
|
|
262
|
+
for (const check of checks) {
|
|
263
|
+
console.log(`${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (failed === 0) console.log("\nAll checks passed.")
|
|
267
|
+
else console.error(`\n${failed} check${failed === 1 ? "" : "s"} failed.`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (failed > 0) process.exitCode = 1
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
program
|
|
274
|
+
.command("logs")
|
|
275
|
+
.description("Print recent stdout/stderr captured from managed processes.")
|
|
276
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
277
|
+
.option("--process <id>", "Only show logs for the process with this id")
|
|
278
|
+
.option("--json", "Output machine-readable JSON")
|
|
279
|
+
.action(async (options) => {
|
|
280
|
+
const configPath = await resolveConfigPath(options.config)
|
|
281
|
+
const config = await loadConfig(configPath)
|
|
282
|
+
const response = await sendControlCommand({
|
|
283
|
+
command: {command: "status"},
|
|
284
|
+
path: config.control.path
|
|
285
|
+
})
|
|
286
|
+
const sources = collectLogSources(/** @type {import("./daemon.js").DaemonStatus} */ (response))
|
|
287
|
+
|
|
288
|
+
if (options.json) {
|
|
289
|
+
const filtered = options.process === undefined ? sources : sources.filter((source) => source.id === options.process)
|
|
290
|
+
|
|
291
|
+
console.log(JSON.stringify(filtered, null, 2))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log(formatLogSources(sources, options.process))
|
|
296
|
+
})
|
|
297
|
+
|
|
186
298
|
await program.parseAsync(argv)
|
|
187
299
|
}
|
|
188
300
|
|
|
301
|
+
/**
|
|
302
|
+
* @typedef {{id: string, logs: import("./managed-process.js").ManagedProcessLog[], source: string}} LogSource
|
|
303
|
+
*/
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Flattens managed-process logs from a daemon status payload, labelling each process by origin.
|
|
307
|
+
* @param {import("./daemon.js").DaemonStatus} status - Daemon status payload.
|
|
308
|
+
* @returns {LogSource[]} One entry per managed process.
|
|
309
|
+
*/
|
|
310
|
+
function collectLogSources(status) {
|
|
311
|
+
/** @type {LogSource[]} */
|
|
312
|
+
const sources = []
|
|
313
|
+
|
|
314
|
+
for (const release of status.releases) {
|
|
315
|
+
for (const processStatus of release.processes) {
|
|
316
|
+
sources.push({id: processStatus.id, logs: processStatus.logs, source: `release ${release.releaseId} (${release.state})`})
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const service of status.services) {
|
|
321
|
+
sources.push({id: service.process.id, logs: service.process.logs, source: "service"})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const singleton of status.singletons) {
|
|
325
|
+
sources.push({id: singleton.process.id, logs: singleton.process.logs, source: "singleton"})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return sources
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Formats collected log sources for display, optionally filtered to a single process id.
|
|
333
|
+
* @param {LogSource[]} sources - Collected log sources.
|
|
334
|
+
* @param {string | undefined} processFilter - Only include the process with this id when set.
|
|
335
|
+
* @returns {string} Human-readable log output.
|
|
336
|
+
*/
|
|
337
|
+
export function formatLogSources(sources, processFilter) {
|
|
338
|
+
const matched = processFilter === undefined ? sources : sources.filter((source) => source.id === processFilter)
|
|
339
|
+
|
|
340
|
+
if (matched.length === 0) {
|
|
341
|
+
return processFilter === undefined ? "No managed processes." : `No process found with id "${processFilter}".`
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return matched
|
|
345
|
+
.map((source) => {
|
|
346
|
+
const header = `== ${source.id} [${source.source}] ==`
|
|
347
|
+
|
|
348
|
+
if (source.logs.length === 0) return `${header}\n (no recent output)`
|
|
349
|
+
|
|
350
|
+
return `${header}\n${source.logs.map((log) => ` ${log.at} [${log.stream}] ${log.line}`).join("\n")}`
|
|
351
|
+
})
|
|
352
|
+
.join("\n\n")
|
|
353
|
+
}
|
|
354
|
+
|
|
189
355
|
/**
|
|
190
356
|
* Reads, parses, and validates a config file, collecting read, parse, and validation issues.
|
|
191
357
|
* @param {string} configPath - Config file path.
|
package/src/config.js
CHANGED
|
@@ -7,12 +7,14 @@ import {pathToFileURL} from "node:url"
|
|
|
7
7
|
/**
|
|
8
8
|
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
9
9
|
* @typedef {{from: number, to: number}} PortRange
|
|
10
|
-
* @typedef {{path: string, timeoutMs: number, intervalMs: number}} HealthConfig
|
|
10
|
+
* @typedef {{path: string, startDelayMs: number, timeoutMs: number, intervalMs: number}} HealthConfig
|
|
11
11
|
* @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
|
|
12
|
-
* @typedef {{
|
|
12
|
+
* @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
|
|
13
|
+
* @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, outputLines: number, policy: ProcessPolicy, port?: PortRange, restart: RestartConfig, restartDelayMs: number, command: string}} ProcessConfig
|
|
13
14
|
* @typedef {{mode?: number, path: string}} ControlConfig
|
|
14
|
-
* @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number}} ProxyConfig
|
|
15
|
-
* @typedef {{
|
|
15
|
+
* @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number, upstreamHost: string}} ProxyConfig
|
|
16
|
+
* @typedef {{keep: number, maxAgeMs: number}} ReleaseRetentionConfig
|
|
17
|
+
* @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig, releaseRetention: ReleaseRetentionConfig}} RollbridgeConfig
|
|
16
18
|
* @typedef {{fix: string, message: string}} ConfigIssue
|
|
17
19
|
*/
|
|
18
20
|
|
|
@@ -127,10 +129,11 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
|
|
|
127
129
|
path: normalizeString(controlSource.path, "control.path", issues, {default: `/tmp/rollbridge-${application}.sock`})
|
|
128
130
|
}
|
|
129
131
|
const processes = processesSource.map((processSource, index) => normalizeProcess(processSource, index, proxy, issues))
|
|
132
|
+
const releaseRetention = normalizeReleaseRetention(objectAt(source.releaseRetention, "releaseRetention", issues, {}), issues)
|
|
130
133
|
|
|
131
134
|
validateProcessSet(processes, issues)
|
|
132
135
|
|
|
133
|
-
return {config: {application, control, processes, proxy}, issues}
|
|
136
|
+
return {config: {application, control, processes, proxy, releaseRetention}, issues}
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
/**
|
|
@@ -139,16 +142,29 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
|
|
|
139
142
|
* @returns {ProxyConfig} Normalized proxy config.
|
|
140
143
|
*/
|
|
141
144
|
function normalizeProxy(source, issues) {
|
|
145
|
+
const host = normalizeString(source.host, "proxy.host", issues, {default: "127.0.0.1"})
|
|
146
|
+
|
|
142
147
|
return {
|
|
143
148
|
drainTimeoutMs: normalizeNumber(source.drainTimeoutMs, "proxy.drainTimeoutMs", issues, {default: 60000}),
|
|
144
149
|
forceStopTimeoutMs: normalizeNumber(source.forceStopTimeoutMs, "proxy.forceStopTimeoutMs", issues, {default: 10000}),
|
|
145
150
|
healthPath: normalizeString(source.healthPath, "proxy.healthPath", issues, {default: "/ping"}),
|
|
146
151
|
healthTimeoutMs: normalizeNumber(source.healthTimeoutMs, "proxy.healthTimeoutMs", issues, {default: 30000}),
|
|
147
|
-
host
|
|
148
|
-
port: normalizeNumber(source.port, "proxy.port", issues, {default: 8182})
|
|
152
|
+
host,
|
|
153
|
+
port: normalizeNumber(source.port, "proxy.port", issues, {default: 8182}),
|
|
154
|
+
upstreamHost: normalizeString(source.upstreamHost, "proxy.upstreamHost", issues, {default: defaultUpstreamHost(host)})
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
157
|
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} host - Public proxy bind host.
|
|
160
|
+
* @returns {string} Default loopback upstream host for wildcard binds.
|
|
161
|
+
*/
|
|
162
|
+
function defaultUpstreamHost(host) {
|
|
163
|
+
if (host === "0.0.0.0" || host === "::") return "127.0.0.1"
|
|
164
|
+
|
|
165
|
+
return host
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
/**
|
|
153
169
|
* @param {JsonValue} value - Raw process config.
|
|
154
170
|
* @param {number} index - Process index.
|
|
@@ -160,7 +176,7 @@ function normalizeProcess(value, index, proxy, issues) {
|
|
|
160
176
|
if (!isPlainObject(value)) {
|
|
161
177
|
issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
|
|
162
178
|
|
|
163
|
-
return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restartDelayMs: 1000}
|
|
179
|
+
return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restart: defaultRestartConfig(), restartDelayMs: 1000}
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
const source = value
|
|
@@ -175,10 +191,80 @@ function normalizeProcess(value, index, proxy, issues) {
|
|
|
175
191
|
outputLines: normalizeOutputLines(source.outputLines, `processes[${index}].outputLines`, issues),
|
|
176
192
|
policy: normalizePolicy(source.policy, `processes[${index}].policy`, issues),
|
|
177
193
|
port: normalizePortRange(source.port, `processes[${index}].port`, issues),
|
|
194
|
+
restart: normalizeRestart(source.restart, `processes[${index}].restart`, issues),
|
|
178
195
|
restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000})
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
/**
|
|
200
|
+
* @returns {RestartConfig} Default restart policy: unlimited restarts with a constant delay.
|
|
201
|
+
*/
|
|
202
|
+
function defaultRestartConfig() {
|
|
203
|
+
return {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {JsonValue} value - Raw restart policy.
|
|
208
|
+
* @param {string} key - Config key.
|
|
209
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
210
|
+
* @returns {RestartConfig} Normalized restart policy.
|
|
211
|
+
*/
|
|
212
|
+
function normalizeRestart(value, key, issues) {
|
|
213
|
+
if (value === undefined || value === null) return defaultRestartConfig()
|
|
214
|
+
|
|
215
|
+
if (!isPlainObject(value)) {
|
|
216
|
+
issues.push({fix: `Set ${key} to a mapping with maxRestarts, windowMs, backoffFactor, and maxDelayMs.`, message: `${key} must be an object`})
|
|
217
|
+
|
|
218
|
+
return defaultRestartConfig()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const windowMs = normalizeNumber(value.windowMs, `${key}.windowMs`, issues, {default: 0})
|
|
222
|
+
const maxDelayMs = normalizeNumber(value.maxDelayMs, `${key}.maxDelayMs`, issues, {default: 0})
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
backoffFactor: normalizeBackoffFactor(value.backoffFactor, `${key}.backoffFactor`, issues),
|
|
226
|
+
maxDelayMs: nonNegativeOrDefault(maxDelayMs, `${key}.maxDelayMs`, issues, 0, false),
|
|
227
|
+
maxRestarts: normalizeMaxRestarts(value.maxRestarts, `${key}.maxRestarts`, issues),
|
|
228
|
+
windowMs: nonNegativeOrDefault(windowMs, `${key}.windowMs`, issues, 0, false)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {JsonValue} value - Raw maximum restart count.
|
|
234
|
+
* @param {string} key - Config key.
|
|
235
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
236
|
+
* @returns {number | undefined} Restart cap, or undefined for unlimited restarts.
|
|
237
|
+
*/
|
|
238
|
+
function normalizeMaxRestarts(value, key, issues) {
|
|
239
|
+
if (value === undefined || value === null) return undefined
|
|
240
|
+
|
|
241
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
242
|
+
issues.push({fix: `Set ${key} to a non-negative integer (0 disables automatic restarts), or omit it for unlimited restarts.`, message: `${key} must be a non-negative integer`})
|
|
243
|
+
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return value
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {JsonValue} value - Raw backoff factor.
|
|
252
|
+
* @param {string} key - Config key.
|
|
253
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
254
|
+
* @returns {number} Backoff multiplier (>= 1; 1 keeps a constant delay).
|
|
255
|
+
*/
|
|
256
|
+
function normalizeBackoffFactor(value, key, issues) {
|
|
257
|
+
const factor = normalizeNumber(value, key, issues, {default: 1})
|
|
258
|
+
|
|
259
|
+
if (factor < 1) {
|
|
260
|
+
issues.push({fix: `Set ${key} to a number >= 1 (1 keeps a constant delay; 2 doubles the delay each restart).`, message: `${key} must be a number greater than or equal to 1`})
|
|
261
|
+
|
|
262
|
+
return 1
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return factor
|
|
266
|
+
}
|
|
267
|
+
|
|
182
268
|
/**
|
|
183
269
|
* @param {JsonValue} value - Raw output retention value.
|
|
184
270
|
* @param {string} key - Config key.
|
|
@@ -197,6 +283,39 @@ function normalizeOutputLines(value, key, issues) {
|
|
|
197
283
|
return outputLines
|
|
198
284
|
}
|
|
199
285
|
|
|
286
|
+
/**
|
|
287
|
+
* @param {Record<string, JsonValue>} source - Raw release retention config.
|
|
288
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
289
|
+
* @returns {ReleaseRetentionConfig} Normalized release retention policy.
|
|
290
|
+
*/
|
|
291
|
+
function normalizeReleaseRetention(source, issues) {
|
|
292
|
+
const keep = normalizeNumber(source.keep, "releaseRetention.keep", issues, {default: 10})
|
|
293
|
+
const maxAgeMs = normalizeNumber(source.maxAgeMs, "releaseRetention.maxAgeMs", issues, {default: 0})
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
keep: nonNegativeOrDefault(keep, "releaseRetention.keep", issues, 10, true),
|
|
297
|
+
maxAgeMs: nonNegativeOrDefault(maxAgeMs, "releaseRetention.maxAgeMs", issues, 0, false)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {number} value - Already type-normalized number.
|
|
303
|
+
* @param {string} key - Config key.
|
|
304
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
305
|
+
* @param {number} fallback - Value to use when invalid.
|
|
306
|
+
* @param {boolean} requireInteger - Whether the value must be an integer.
|
|
307
|
+
* @returns {number} The value when non-negative (and integer when required), else the fallback.
|
|
308
|
+
*/
|
|
309
|
+
function nonNegativeOrDefault(value, key, issues, fallback, requireInteger) {
|
|
310
|
+
if (value < 0 || (requireInteger && !Number.isInteger(value))) {
|
|
311
|
+
issues.push({fix: `Set ${key} to a non-negative ${requireInteger ? "integer" : "number"}, e.g. ${fallback}.`, message: `${key} must be a non-negative ${requireInteger ? "integer" : "number"}`})
|
|
312
|
+
|
|
313
|
+
return fallback
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return value
|
|
317
|
+
}
|
|
318
|
+
|
|
200
319
|
/**
|
|
201
320
|
* @param {JsonValue} value - Raw socket permission mode.
|
|
202
321
|
* @param {string} key - Config key.
|
|
@@ -291,10 +410,29 @@ function normalizeHealth(value, key, proxy, issues) {
|
|
|
291
410
|
return {
|
|
292
411
|
intervalMs: normalizeNumber(source.intervalMs, `${key}.intervalMs`, issues, {default: 250}),
|
|
293
412
|
path: normalizeString(source.path, `${key}.path`, issues, {default: proxy.healthPath}),
|
|
413
|
+
startDelayMs: normalizeStartDelayMs(source.startDelayMs, `${key}.startDelayMs`, issues),
|
|
294
414
|
timeoutMs: normalizeNumber(source.timeoutMs, `${key}.timeoutMs`, issues, {default: proxy.healthTimeoutMs})
|
|
295
415
|
}
|
|
296
416
|
}
|
|
297
417
|
|
|
418
|
+
/**
|
|
419
|
+
* @param {JsonValue} value - Raw startup delay.
|
|
420
|
+
* @param {string} key - Config key.
|
|
421
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
422
|
+
* @returns {number} Milliseconds to wait before the first health probe (default 0).
|
|
423
|
+
*/
|
|
424
|
+
function normalizeStartDelayMs(value, key, issues) {
|
|
425
|
+
const startDelayMs = normalizeNumber(value, key, issues, {default: 0})
|
|
426
|
+
|
|
427
|
+
if (startDelayMs < 0) {
|
|
428
|
+
issues.push({fix: `Set ${key} to a non-negative number of milliseconds, e.g. 0 or 2000.`, message: `${key} must be a non-negative number`})
|
|
429
|
+
|
|
430
|
+
return 0
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return startDelayMs
|
|
434
|
+
}
|
|
435
|
+
|
|
298
436
|
/**
|
|
299
437
|
* @param {JsonValue} value - Raw env config.
|
|
300
438
|
* @param {string} key - Config key.
|
package/src/daemon.js
CHANGED
|
@@ -10,7 +10,7 @@ import ReleaseGroup from "./release-group.js"
|
|
|
10
10
|
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
11
11
|
* @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
|
|
12
12
|
* @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
|
|
13
|
-
* @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, proxy: {host: string, port: number | undefined}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
13
|
+
* @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, proxy: {host: string, port: number | undefined, upstreamHost: string}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export default class RollbridgeDaemon {
|
|
@@ -233,6 +233,13 @@ export default class RollbridgeDaemon {
|
|
|
233
233
|
return this.status()
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
if (commandName === "restart") {
|
|
237
|
+
return await this.restartProcesses({
|
|
238
|
+
policy: stringOrUndefined(data.policy),
|
|
239
|
+
processId: stringOrUndefined(data.processId)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
236
243
|
if (commandName === "shutdown") {
|
|
237
244
|
setImmediate(() => {
|
|
238
245
|
this.shutdown().catch((error) => {
|
|
@@ -286,9 +293,7 @@ export default class RollbridgeDaemon {
|
|
|
286
293
|
await this.replaceSingletons(release)
|
|
287
294
|
|
|
288
295
|
if (previousRelease) {
|
|
289
|
-
|
|
290
|
-
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: previousRelease.releaseId})
|
|
291
|
-
})
|
|
296
|
+
void this.drainAndPrune(previousRelease)
|
|
292
297
|
}
|
|
293
298
|
|
|
294
299
|
return {
|
|
@@ -367,6 +372,7 @@ export default class RollbridgeDaemon {
|
|
|
367
372
|
env: nextDefinition.env,
|
|
368
373
|
logger: nextDefinition.logger,
|
|
369
374
|
outputLines: nextDefinition.outputLines,
|
|
375
|
+
restart: nextDefinition.restart,
|
|
370
376
|
restartDelayMs: nextDefinition.restartDelayMs,
|
|
371
377
|
shouldRestart: nextDefinition.shouldRestart,
|
|
372
378
|
stopTimeoutMs: nextDefinition.stopTimeoutMs
|
|
@@ -396,6 +402,75 @@ export default class RollbridgeDaemon {
|
|
|
396
402
|
}
|
|
397
403
|
}
|
|
398
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Restarts non-proxied processes selected by id or policy, or all of them: running
|
|
407
|
+
* processes are bounced (stop then start) and crashed or stopped ones are revived,
|
|
408
|
+
* matching the conventional meaning of "restart".
|
|
409
|
+
*
|
|
410
|
+
* The proxied process is never restarted in place (that would drop traffic); use a
|
|
411
|
+
* deploy for a zero-downtime replacement.
|
|
412
|
+
* @param {{policy?: string, processId?: string}} selector - Restart selector; restarts all non-proxied processes when both are omitted.
|
|
413
|
+
* @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
|
|
414
|
+
*/
|
|
415
|
+
async restartProcesses({policy, processId} = {}) {
|
|
416
|
+
if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
|
|
417
|
+
throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const targets = this.collectRestartTargets({policy, processId})
|
|
421
|
+
|
|
422
|
+
if (processId !== undefined && targets.length === 0) {
|
|
423
|
+
throw new Error(`No managed process with id "${processId}" to restart.`)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const target of targets) {
|
|
427
|
+
this.logger("process restart requested", {processId: target.id})
|
|
428
|
+
await target.process.stop()
|
|
429
|
+
await target.process.start()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {restarted: targets.map((target) => target.id)}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* @param {{policy?: string, processId?: string}} selector - Restart selector.
|
|
437
|
+
* @returns {{id: string, process: import("./managed-process.js").default}[]} Running non-proxied processes matching the selector.
|
|
438
|
+
*/
|
|
439
|
+
collectRestartTargets({policy, processId}) {
|
|
440
|
+
const targets = /** @type {{id: string, process: import("./managed-process.js").default}[]} */ ([])
|
|
441
|
+
|
|
442
|
+
for (const processConfig of this.config.processes) {
|
|
443
|
+
if (processConfig.policy === "proxied") continue
|
|
444
|
+
if (processId !== undefined && processConfig.id !== processId) continue
|
|
445
|
+
if (policy !== undefined && processConfig.policy !== policy) continue
|
|
446
|
+
|
|
447
|
+
const process = this.findProcessInstance(processConfig)
|
|
448
|
+
|
|
449
|
+
if (process) targets.push({id: processConfig.id, process})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return targets
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @param {import("./config.js").ProcessConfig} processConfig - Process definition.
|
|
457
|
+
* @returns {import("./managed-process.js").default | undefined} The running instance, if any.
|
|
458
|
+
*/
|
|
459
|
+
findProcessInstance(processConfig) {
|
|
460
|
+
if (processConfig.policy === "service") return this.services.get(processConfig.id)
|
|
461
|
+
if (processConfig.policy === "singleton") return this.singletons.get(processConfig.id)
|
|
462
|
+
|
|
463
|
+
return this.activeRelease ? this.activeRelease.getProcess(processConfig.id) : undefined
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* @param {string} id - Process id.
|
|
468
|
+
* @returns {boolean} True when the id belongs to the proxied process.
|
|
469
|
+
*/
|
|
470
|
+
isProxiedId(id) {
|
|
471
|
+
return this.config.processes.some((processConfig) => processConfig.policy === "proxied" && processConfig.id === id)
|
|
472
|
+
}
|
|
473
|
+
|
|
399
474
|
/**
|
|
400
475
|
* @param {string | undefined} releaseId - Release id, or active release when omitted.
|
|
401
476
|
* @returns {Promise<void>} Resolves when stopped.
|
|
@@ -407,6 +482,31 @@ export default class RollbridgeDaemon {
|
|
|
407
482
|
if (release === this.activeRelease) this.activeRelease = undefined
|
|
408
483
|
|
|
409
484
|
await release.stop()
|
|
485
|
+
this.pruneStoppedReleases()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
490
|
+
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
491
|
+
* @returns {Promise<void>} Resolves once drained, stopped, and pruned.
|
|
492
|
+
*/
|
|
493
|
+
async drainAndPrune(release) {
|
|
494
|
+
try {
|
|
495
|
+
await release.drainAndStop(this.config.proxy.drainTimeoutMs)
|
|
496
|
+
} catch (error) {
|
|
497
|
+
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
|
|
498
|
+
} finally {
|
|
499
|
+
this.pruneStoppedReleases()
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** @returns {void} Removes stopped releases beyond the retention policy. */
|
|
504
|
+
pruneStoppedReleases() {
|
|
505
|
+
const statuses = [...this.releases.values()].map((release) => release.status())
|
|
506
|
+
|
|
507
|
+
for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
|
|
508
|
+
this.releases.delete(releaseId)
|
|
509
|
+
}
|
|
410
510
|
}
|
|
411
511
|
|
|
412
512
|
/** @returns {Promise<void>} Stops proxy, control socket, and child processes. */
|
|
@@ -446,7 +546,8 @@ export default class RollbridgeDaemon {
|
|
|
446
546
|
control: {...this.config.control},
|
|
447
547
|
proxy: {
|
|
448
548
|
host: this.config.proxy.host,
|
|
449
|
-
port: this.proxyPort ?? this.config.proxy.port
|
|
549
|
+
port: this.proxyPort ?? this.config.proxy.port,
|
|
550
|
+
upstreamHost: this.config.proxy.upstreamHost
|
|
450
551
|
},
|
|
451
552
|
releases: [...this.releases.values()].map((release) => release.status()),
|
|
452
553
|
services: [...this.services.entries()].map(([id, processInstance]) => ({
|
|
@@ -485,6 +586,37 @@ function requiredString(value, key) {
|
|
|
485
586
|
return value
|
|
486
587
|
}
|
|
487
588
|
|
|
589
|
+
/**
|
|
590
|
+
* @typedef {{releaseId: string, state: string, stoppedAt: string | undefined}} PrunableRelease
|
|
591
|
+
*/
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Selects stopped releases to prune by the retention policy, keeping the most recent.
|
|
595
|
+
* @param {PrunableRelease[]} releases - Status of all tracked releases, in deploy order (oldest first).
|
|
596
|
+
* @param {import("./config.js").ReleaseRetentionConfig} policy - Retention policy.
|
|
597
|
+
* @param {number} now - Current epoch milliseconds.
|
|
598
|
+
* @returns {string[]} Release ids to remove.
|
|
599
|
+
*/
|
|
600
|
+
export function releasesToPrune(releases, policy, now) {
|
|
601
|
+
const stopped = releases
|
|
602
|
+
.filter((release) => release.state === "stopped")
|
|
603
|
+
.map((release, index) => ({deployOrder: index, releaseId: release.releaseId, stoppedAtMs: release.stoppedAt ? Date.parse(release.stoppedAt) : 0}))
|
|
604
|
+
// Most recent first; ties (same stoppedAt millisecond) prefer the later-deployed release.
|
|
605
|
+
.sort((first, second) => second.stoppedAtMs - first.stoppedAtMs || second.deployOrder - first.deployOrder)
|
|
606
|
+
|
|
607
|
+
/** @type {string[]} */
|
|
608
|
+
const remove = []
|
|
609
|
+
|
|
610
|
+
stopped.forEach((release, index) => {
|
|
611
|
+
const beyondKeep = index >= policy.keep
|
|
612
|
+
const tooOld = policy.maxAgeMs > 0 && release.stoppedAtMs > 0 && now - release.stoppedAtMs > policy.maxAgeMs
|
|
613
|
+
|
|
614
|
+
if (beyondKeep || tooOld) remove.push(release.releaseId)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
return remove
|
|
618
|
+
}
|
|
619
|
+
|
|
488
620
|
/**
|
|
489
621
|
* @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null}} ControlSocketInspection
|
|
490
622
|
*/
|
|
@@ -512,7 +644,7 @@ function controlSocketBusyMessage(socketPath, inspection) {
|
|
|
512
644
|
* @param {number} [timeoutMs] - How long to wait for a status response before treating the socket as busy.
|
|
513
645
|
* @returns {Promise<ControlSocketInspection>} Whether the socket is live and, when it is Rollbridge, its identity.
|
|
514
646
|
*/
|
|
515
|
-
async function inspectControlSocket(socketPath, timeoutMs = 1000) {
|
|
647
|
+
export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
|
|
516
648
|
return await new Promise((resolve, reject) => {
|
|
517
649
|
const socket = net.createConnection(socketPath)
|
|
518
650
|
let buffer = ""
|