rollbridge 0.1.4 → 0.1.6
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/README.md +137 -4
- package/TODO.md +47 -45
- package/docs/cli.md +169 -6
- package/docs/config.md +160 -3
- package/docs/logging.md +77 -0
- package/docs/nginx.md +104 -0
- package/docs/releasing.md +53 -0
- package/docs/tensorbuzz-runbook.md +129 -0
- package/docs/velocious.md +238 -0
- package/docs/workers.md +115 -0
- package/package.json +3 -2
- package/src/cli.js +317 -1
- package/src/config.js +240 -6
- package/src/daemon.js +284 -4
- package/src/doctor.js +177 -0
- package/src/event-log.js +47 -0
- package/src/managed-process.js +287 -22
- package/src/process-memory.js +110 -0
- package/src/recover.js +134 -0
- package/src/release-group.js +80 -21
- package/src/state-store.js +103 -0
- package/src/system-ids.js +71 -0
- package/src/template.js +32 -0
- package/test/completion.test.js +64 -0
- package/test/config-validation.test.js +267 -0
- package/test/doctor.test.js +205 -3
- package/test/event-log.test.js +46 -0
- package/test/fixtures/memory-hog.js +19 -0
- package/test/managed-process.test.js +376 -0
- package/test/process-memory.test.js +40 -0
- package/test/recover.test.js +162 -0
- package/test/release-group.test.js +22 -0
- package/test/rollbridge.test.js +716 -6
- package/test/state-store.test.js +69 -0
- package/test/system-ids.test.js +24 -0
- package/scripts/release-patch.js +0 -83
package/src/cli.js
CHANGED
|
@@ -7,7 +7,8 @@ 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
|
+
import {runEnvironmentChecks, runReleaseChecks} from "./doctor.js"
|
|
11
|
+
import {recoverOrphans} from "./recover.js"
|
|
11
12
|
import {sendControlCommand} from "./control-client.js"
|
|
12
13
|
|
|
13
14
|
const DEFAULT_DAEMON_START_TIMEOUT_MS = 10000
|
|
@@ -82,6 +83,25 @@ export async function runCli(argv) {
|
|
|
82
83
|
console.log(JSON.stringify(response, null, 2))
|
|
83
84
|
})
|
|
84
85
|
|
|
86
|
+
program
|
|
87
|
+
.command("rollback")
|
|
88
|
+
.description("Roll back to a previous release: re-start it, health-check it, and switch traffic.")
|
|
89
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
90
|
+
.option("--release-id <id>", "Release id to roll back to (defaults to the most recently retired release)")
|
|
91
|
+
.action(async (options) => {
|
|
92
|
+
const configPath = await resolveConfigPath(options.config)
|
|
93
|
+
const config = await loadConfig(configPath)
|
|
94
|
+
const response = await sendControlCommand({
|
|
95
|
+
command: {
|
|
96
|
+
command: "rollback",
|
|
97
|
+
releaseId: options.releaseId
|
|
98
|
+
},
|
|
99
|
+
path: config.control.path
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
console.log(JSON.stringify(response, null, 2))
|
|
103
|
+
})
|
|
104
|
+
|
|
85
105
|
program
|
|
86
106
|
.command("ensure-daemon")
|
|
87
107
|
.description("Start the daemon if the control socket is not already accepting commands.")
|
|
@@ -136,6 +156,33 @@ export async function runCli(argv) {
|
|
|
136
156
|
console.log(JSON.stringify(response, null, 2))
|
|
137
157
|
})
|
|
138
158
|
|
|
159
|
+
program
|
|
160
|
+
.command("restart")
|
|
161
|
+
.description("Restart running non-proxied processes (by id, by policy, or all).")
|
|
162
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
163
|
+
.option("--process <id>", "Restart only the process with this id")
|
|
164
|
+
.option("--policy <policy>", "Restart only processes with this policy (companion, singleton, or service)")
|
|
165
|
+
.action(async (options) => {
|
|
166
|
+
if (options.policy !== undefined && !["companion", "service", "singleton"].includes(options.policy)) {
|
|
167
|
+
console.error("--policy must be one of: companion, singleton, service.")
|
|
168
|
+
process.exitCode = 1
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const configPath = await resolveConfigPath(options.config)
|
|
173
|
+
const config = await loadConfig(configPath)
|
|
174
|
+
const response = await sendControlCommand({
|
|
175
|
+
command: {
|
|
176
|
+
command: "restart",
|
|
177
|
+
policy: options.policy,
|
|
178
|
+
processId: options.process
|
|
179
|
+
},
|
|
180
|
+
path: config.control.path
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
console.log(JSON.stringify(response, null, 2))
|
|
184
|
+
})
|
|
185
|
+
|
|
139
186
|
program
|
|
140
187
|
.command("shutdown")
|
|
141
188
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
@@ -201,6 +248,9 @@ export async function runCli(argv) {
|
|
|
201
248
|
.command("doctor")
|
|
202
249
|
.description("Check the environment before starting the daemon: config, control socket, and proxy port.")
|
|
203
250
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
251
|
+
.option("--release-path <path>", "Also pre-flight a prepared release: render its templates and check the release and working directories")
|
|
252
|
+
.option("--release-id <id>", "Release id used when rendering templates (defaults to --revision or the release path basename)")
|
|
253
|
+
.option("--revision <sha>", "Revision used when rendering templates (defaults to --release-id)")
|
|
204
254
|
.option("--json", "Output machine-readable JSON")
|
|
205
255
|
.action(async (options) => {
|
|
206
256
|
let configPath
|
|
@@ -225,6 +275,10 @@ export async function runCli(argv) {
|
|
|
225
275
|
} else {
|
|
226
276
|
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})
|
|
227
277
|
checks.push(...await runEnvironmentChecks(config))
|
|
278
|
+
|
|
279
|
+
if (options.releasePath) {
|
|
280
|
+
checks.push(...await runReleaseChecks(config, {releaseId: options.releaseId, releasePath: options.releasePath, revision: options.revision}))
|
|
281
|
+
}
|
|
228
282
|
}
|
|
229
283
|
|
|
230
284
|
const failed = checks.filter((check) => !check.ok).length
|
|
@@ -268,9 +322,271 @@ export async function runCli(argv) {
|
|
|
268
322
|
console.log(formatLogSources(sources, options.process))
|
|
269
323
|
})
|
|
270
324
|
|
|
325
|
+
program
|
|
326
|
+
.command("events")
|
|
327
|
+
.description("Print recent structured daemon events (deploys, switches, stops, crashes, restarts, failures).")
|
|
328
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
329
|
+
.option("--limit <count>", "Show only the most recent <count> events")
|
|
330
|
+
.option("--json", "Output machine-readable JSON")
|
|
331
|
+
.action(async (options) => {
|
|
332
|
+
let limit
|
|
333
|
+
|
|
334
|
+
if (options.limit !== undefined) {
|
|
335
|
+
limit = Number(options.limit)
|
|
336
|
+
|
|
337
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
338
|
+
console.error("--limit must be a positive integer.")
|
|
339
|
+
process.exitCode = 1
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const configPath = await resolveConfigPath(options.config)
|
|
345
|
+
const config = await loadConfig(configPath)
|
|
346
|
+
const response = await sendControlCommand({
|
|
347
|
+
command: {command: "events", limit},
|
|
348
|
+
path: config.control.path
|
|
349
|
+
})
|
|
350
|
+
const events = /** @type {import("./event-log.js").DaemonEvent[]} */ (response.events ?? [])
|
|
351
|
+
|
|
352
|
+
if (options.json) {
|
|
353
|
+
console.log(JSON.stringify(events, null, 2))
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log(formatEvents(events))
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
program
|
|
361
|
+
.command("recover")
|
|
362
|
+
.description("Stop orphaned processes left by a crashed daemon (reads statePath; lists them unless --force).")
|
|
363
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
364
|
+
.option("--force", "Stop the orphaned processes; without it, recover only lists them")
|
|
365
|
+
.action(async (options) => {
|
|
366
|
+
const configPath = await resolveConfigPath(options.config)
|
|
367
|
+
const config = await loadConfig(configPath)
|
|
368
|
+
const result = await recoverOrphans({config, force: Boolean(options.force)})
|
|
369
|
+
|
|
370
|
+
if ("error" in result) {
|
|
371
|
+
console.error(result.error)
|
|
372
|
+
process.exitCode = 1
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (result.remaining.length > 0) {
|
|
377
|
+
console.error(formatRecoverResult(result))
|
|
378
|
+
process.exitCode = 1
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
console.log(formatRecoverResult(result))
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
program
|
|
386
|
+
.command("completion")
|
|
387
|
+
.description("Print a shell completion script. Enable with: source <(rollbridge completion <shell>)")
|
|
388
|
+
.argument("<shell>", "Shell to generate completion for (bash or zsh)")
|
|
389
|
+
.action((shell) => {
|
|
390
|
+
if (shell !== "bash" && shell !== "zsh") {
|
|
391
|
+
console.error(`Unsupported shell "${shell}". Supported shells: bash, zsh.`)
|
|
392
|
+
process.exitCode = 1
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.log(generateCompletionScript(program, shell))
|
|
397
|
+
})
|
|
398
|
+
|
|
271
399
|
await program.parseAsync(argv)
|
|
272
400
|
}
|
|
273
401
|
|
|
402
|
+
/**
|
|
403
|
+
* @typedef {import("./recover.js").OrphanProcess} OrphanProcess
|
|
404
|
+
*/
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Formats the result of a recover run (the report case, not the error case).
|
|
408
|
+
* @param {{cleared: boolean, forced: boolean, orphans: OrphanProcess[], remaining: OrphanProcess[]}} result - Recover report.
|
|
409
|
+
* @returns {string} Human-readable summary.
|
|
410
|
+
*/
|
|
411
|
+
export function formatRecoverResult(result) {
|
|
412
|
+
if (result.orphans.length === 0) {
|
|
413
|
+
return result.forced ? "No orphaned processes found; cleared the state file." : "No orphaned processes found."
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!result.forced) {
|
|
417
|
+
return [
|
|
418
|
+
`Found ${orphanCountLabel(result.orphans.length)} (run with --force to stop):`,
|
|
419
|
+
...listOrphans(result.orphans),
|
|
420
|
+
"Review the list first — a recycled pid could be an unrelated process."
|
|
421
|
+
].join("\n")
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const remainingPids = new Set(result.remaining.map((orphan) => orphan.pid))
|
|
425
|
+
const stopped = result.orphans.filter((orphan) => !remainingPids.has(orphan.pid))
|
|
426
|
+
const lines = []
|
|
427
|
+
|
|
428
|
+
if (stopped.length > 0) lines.push(`Stopped ${orphanCountLabel(stopped.length)}:`, ...listOrphans(stopped))
|
|
429
|
+
|
|
430
|
+
if (result.remaining.length > 0) {
|
|
431
|
+
lines.push(
|
|
432
|
+
`Could not stop ${orphanCountLabel(result.remaining.length)} — still running (check permissions/ownership):`,
|
|
433
|
+
...listOrphans(result.remaining),
|
|
434
|
+
"Left the state file in place so you can investigate and re-run recover."
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return lines.join("\n")
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @param {number} count - Number of orphaned processes.
|
|
443
|
+
* @returns {string} A pluralized label such as "1 orphaned process" or "3 orphaned processes".
|
|
444
|
+
*/
|
|
445
|
+
function orphanCountLabel(count) {
|
|
446
|
+
return `${count} orphaned process${count === 1 ? "" : "es"}`
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* @param {OrphanProcess[]} orphans - Orphans to render.
|
|
451
|
+
* @returns {string[]} One indented line per orphan.
|
|
452
|
+
*/
|
|
453
|
+
function listOrphans(orphans) {
|
|
454
|
+
return orphans.map((orphan) => ` ${orphan.id} (pid ${orphan.pid}${orphan.releaseId ? `, release ${orphan.releaseId}` : ""})`)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @typedef {{name: string, options: string[], valueOptions: string[]}} CompletionCommand
|
|
459
|
+
*/
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Builds a shell completion script by introspecting the CLI's commands and options,
|
|
463
|
+
* so completions never drift from the actual command surface.
|
|
464
|
+
* @param {import("commander").Command} program - Configured CLI program.
|
|
465
|
+
* @param {"bash" | "zsh"} shell - Target shell.
|
|
466
|
+
* @returns {string} A sourceable completion script.
|
|
467
|
+
*/
|
|
468
|
+
export function generateCompletionScript(program, shell) {
|
|
469
|
+
const commands = describeCommands(program)
|
|
470
|
+
|
|
471
|
+
return shell === "zsh" ? zshCompletionScript(commands) : bashCompletionScript(commands)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @param {import("commander").Command} program - Configured CLI program.
|
|
476
|
+
* @returns {CompletionCommand[]} Each command's name, long option flags, and value-taking option flags.
|
|
477
|
+
*/
|
|
478
|
+
function describeCommands(program) {
|
|
479
|
+
return program.commands.map((command) => {
|
|
480
|
+
/** @type {string[]} */
|
|
481
|
+
const options = []
|
|
482
|
+
/** @type {string[]} */
|
|
483
|
+
const valueOptions = []
|
|
484
|
+
|
|
485
|
+
for (const option of command.options) {
|
|
486
|
+
if (!option.long) continue
|
|
487
|
+
|
|
488
|
+
options.push(option.long)
|
|
489
|
+
if (option.required || option.optional) valueOptions.push(option.long)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {name: command.name(), options, valueOptions}
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @param {CompletionCommand[]} commands - Command descriptors.
|
|
498
|
+
* @returns {string} A bash completion script.
|
|
499
|
+
*/
|
|
500
|
+
function bashCompletionScript(commands) {
|
|
501
|
+
const names = commands.map((command) => command.name).join(" ")
|
|
502
|
+
const branches = commands
|
|
503
|
+
.map((command) => ` ${command.name})\n opts="${command.options.join(" ")}"\n values="${command.valueOptions.join(" ")}"\n ;;`)
|
|
504
|
+
.join("\n")
|
|
505
|
+
|
|
506
|
+
return `# rollbridge bash completion
|
|
507
|
+
# Enable with: source <(rollbridge completion bash)
|
|
508
|
+
_rollbridge() {
|
|
509
|
+
local cur prev cmd opts values i
|
|
510
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
511
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
512
|
+
|
|
513
|
+
cmd=""
|
|
514
|
+
for ((i = 1; i < COMP_CWORD; i++)); do
|
|
515
|
+
case "\${COMP_WORDS[i]}" in
|
|
516
|
+
-*) ;;
|
|
517
|
+
*) cmd="\${COMP_WORDS[i]}"; break ;;
|
|
518
|
+
esac
|
|
519
|
+
done
|
|
520
|
+
|
|
521
|
+
if [[ -z "$cmd" ]]; then
|
|
522
|
+
COMPREPLY=( $(compgen -W "${names}" -- "$cur") )
|
|
523
|
+
return
|
|
524
|
+
fi
|
|
525
|
+
|
|
526
|
+
opts=""
|
|
527
|
+
values=""
|
|
528
|
+
case "$cmd" in
|
|
529
|
+
${branches}
|
|
530
|
+
esac
|
|
531
|
+
|
|
532
|
+
if [[ -n "$values" && " $values " == *" $prev "* ]]; then
|
|
533
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
534
|
+
return
|
|
535
|
+
fi
|
|
536
|
+
|
|
537
|
+
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
|
|
538
|
+
}
|
|
539
|
+
complete -F _rollbridge rollbridge
|
|
540
|
+
`
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* @param {CompletionCommand[]} commands - Command descriptors.
|
|
545
|
+
* @returns {string} A zsh completion script.
|
|
546
|
+
*/
|
|
547
|
+
function zshCompletionScript(commands) {
|
|
548
|
+
const names = commands.map((command) => command.name).join(" ")
|
|
549
|
+
const branches = commands
|
|
550
|
+
.map((command) => ` ${command.name}) compadd -- ${command.options.join(" ")} ;;`)
|
|
551
|
+
.join("\n")
|
|
552
|
+
|
|
553
|
+
return `#compdef rollbridge
|
|
554
|
+
# rollbridge zsh completion
|
|
555
|
+
# Enable with: source <(rollbridge completion zsh)
|
|
556
|
+
_rollbridge() {
|
|
557
|
+
local -a commands
|
|
558
|
+
commands=(${names})
|
|
559
|
+
|
|
560
|
+
if (( CURRENT == 2 )); then
|
|
561
|
+
compadd -- $commands
|
|
562
|
+
return
|
|
563
|
+
fi
|
|
564
|
+
|
|
565
|
+
case "\${words[2]}" in
|
|
566
|
+
${branches}
|
|
567
|
+
esac
|
|
568
|
+
}
|
|
569
|
+
compdef _rollbridge rollbridge
|
|
570
|
+
`
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Formats structured daemon events as human-readable lines.
|
|
575
|
+
* @param {import("./event-log.js").DaemonEvent[]} events - Recent events, oldest first.
|
|
576
|
+
* @returns {string} One line per event, or a placeholder when empty.
|
|
577
|
+
*/
|
|
578
|
+
export function formatEvents(events) {
|
|
579
|
+
if (events.length === 0) return "No events recorded yet."
|
|
580
|
+
|
|
581
|
+
return events
|
|
582
|
+
.map((event) => {
|
|
583
|
+
const data = Object.keys(event.data).length > 0 ? ` ${JSON.stringify(event.data)}` : ""
|
|
584
|
+
|
|
585
|
+
return `${event.at} ${event.message}${data}`
|
|
586
|
+
})
|
|
587
|
+
.join("\n")
|
|
588
|
+
}
|
|
589
|
+
|
|
274
590
|
/**
|
|
275
591
|
* @typedef {{id: string, logs: import("./managed-process.js").ManagedProcessLog[], source: string}} LogSource
|
|
276
592
|
*/
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
4
5
|
import path from "node:path"
|
|
5
6
|
import {pathToFileURL} from "node:url"
|
|
6
7
|
|
|
@@ -9,11 +10,14 @@ import {pathToFileURL} from "node:url"
|
|
|
9
10
|
* @typedef {{from: number, to: number}} PortRange
|
|
10
11
|
* @typedef {{path: string, startDelayMs: number, timeoutMs: number, intervalMs: number}} HealthConfig
|
|
11
12
|
* @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
|
|
12
|
-
* @typedef {{
|
|
13
|
-
* @typedef {{
|
|
13
|
+
* @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
|
|
14
|
+
* @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
|
|
15
|
+
* @typedef {{drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, stopCommand?: string}} LifecycleConfig
|
|
16
|
+
* @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
|
|
17
|
+
* @typedef {{group?: number | string, mode?: number, owner?: number | string, path: string}} ControlConfig
|
|
14
18
|
* @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number, upstreamHost: string}} ProxyConfig
|
|
15
19
|
* @typedef {{keep: number, maxAgeMs: number}} ReleaseRetentionConfig
|
|
16
|
-
* @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig, releaseRetention: ReleaseRetentionConfig}} RollbridgeConfig
|
|
20
|
+
* @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig, releaseRetention: ReleaseRetentionConfig, statePath?: string}} RollbridgeConfig
|
|
17
21
|
* @typedef {{fix: string, message: string}} ConfigIssue
|
|
18
22
|
*/
|
|
19
23
|
|
|
@@ -124,15 +128,18 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
|
|
|
124
128
|
const processesSource = arrayAt(source.processes, "processes", issues)
|
|
125
129
|
const proxy = normalizeProxy(proxySource, issues)
|
|
126
130
|
const control = {
|
|
131
|
+
group: normalizeControlPrincipal(controlSource.group, "control.group", issues),
|
|
127
132
|
mode: normalizeSocketMode(controlSource.mode, "control.mode", issues),
|
|
133
|
+
owner: normalizeControlPrincipal(controlSource.owner, "control.owner", issues),
|
|
128
134
|
path: normalizeString(controlSource.path, "control.path", issues, {default: `/tmp/rollbridge-${application}.sock`})
|
|
129
135
|
}
|
|
130
136
|
const processes = processesSource.map((processSource, index) => normalizeProcess(processSource, index, proxy, issues))
|
|
131
137
|
const releaseRetention = normalizeReleaseRetention(objectAt(source.releaseRetention, "releaseRetention", issues, {}), issues)
|
|
138
|
+
const statePath = source.statePath === undefined || source.statePath === null ? undefined : normalizeString(source.statePath, "statePath", issues)
|
|
132
139
|
|
|
133
140
|
validateProcessSet(processes, issues)
|
|
134
141
|
|
|
135
|
-
return {config: {application, control, processes, proxy, releaseRetention}, issues}
|
|
142
|
+
return {config: {application, control, processes, proxy, releaseRetention, statePath}, issues}
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
/**
|
|
@@ -175,7 +182,7 @@ function normalizeProcess(value, index, proxy, issues) {
|
|
|
175
182
|
if (!isPlainObject(value)) {
|
|
176
183
|
issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
|
|
177
184
|
|
|
178
|
-
return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restartDelayMs: 1000}
|
|
185
|
+
return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", lifecycle: {drainTimeoutMs: 0}, memory: undefined, nonBlockingDrain: false, outputLines: 50, policy: "companion", port: undefined, replicas: 1, restart: defaultRestartConfig(), restartDelayMs: 1000, stopSignal: "SIGTERM"}
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
const source = value
|
|
@@ -187,13 +194,170 @@ function normalizeProcess(value, index, proxy, issues) {
|
|
|
187
194
|
gracefulStopMs: normalizeNumber(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, issues, {default: proxy.forceStopTimeoutMs}),
|
|
188
195
|
health: normalizeHealth(source.health, `processes[${index}].health`, proxy, issues),
|
|
189
196
|
id: normalizeString(source.id, `processes[${index}].id`, issues),
|
|
197
|
+
lifecycle: normalizeLifecycle(source.lifecycle, `processes[${index}].lifecycle`, issues),
|
|
198
|
+
memory: normalizeMemory(source.memory, `processes[${index}].memory`, issues),
|
|
199
|
+
nonBlockingDrain: normalizeBoolean(source.nonBlockingDrain, `processes[${index}].nonBlockingDrain`, issues, false),
|
|
190
200
|
outputLines: normalizeOutputLines(source.outputLines, `processes[${index}].outputLines`, issues),
|
|
191
201
|
policy: normalizePolicy(source.policy, `processes[${index}].policy`, issues),
|
|
192
202
|
port: normalizePortRange(source.port, `processes[${index}].port`, issues),
|
|
193
|
-
|
|
203
|
+
replicas: normalizeReplicas(source.replicas, `processes[${index}].replicas`, issues),
|
|
204
|
+
restart: normalizeRestart(source.restart, `processes[${index}].restart`, issues),
|
|
205
|
+
restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000}),
|
|
206
|
+
stopSignal: normalizeStopSignal(source.stopSignal, `processes[${index}].stopSignal`, issues)
|
|
194
207
|
}
|
|
195
208
|
}
|
|
196
209
|
|
|
210
|
+
/**
|
|
211
|
+
* @returns {RestartConfig} Default restart policy: unlimited restarts with a constant delay.
|
|
212
|
+
*/
|
|
213
|
+
function defaultRestartConfig() {
|
|
214
|
+
return {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {JsonValue} value - Raw restart policy.
|
|
219
|
+
* @param {string} key - Config key.
|
|
220
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
221
|
+
* @returns {RestartConfig} Normalized restart policy.
|
|
222
|
+
*/
|
|
223
|
+
function normalizeRestart(value, key, issues) {
|
|
224
|
+
if (value === undefined || value === null) return defaultRestartConfig()
|
|
225
|
+
|
|
226
|
+
if (!isPlainObject(value)) {
|
|
227
|
+
issues.push({fix: `Set ${key} to a mapping with maxRestarts, windowMs, backoffFactor, and maxDelayMs.`, message: `${key} must be an object`})
|
|
228
|
+
|
|
229
|
+
return defaultRestartConfig()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const windowMs = normalizeNumber(value.windowMs, `${key}.windowMs`, issues, {default: 0})
|
|
233
|
+
const maxDelayMs = normalizeNumber(value.maxDelayMs, `${key}.maxDelayMs`, issues, {default: 0})
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
backoffFactor: normalizeBackoffFactor(value.backoffFactor, `${key}.backoffFactor`, issues),
|
|
237
|
+
maxDelayMs: nonNegativeOrDefault(maxDelayMs, `${key}.maxDelayMs`, issues, 0, false),
|
|
238
|
+
maxRestarts: normalizeMaxRestarts(value.maxRestarts, `${key}.maxRestarts`, issues),
|
|
239
|
+
windowMs: nonNegativeOrDefault(windowMs, `${key}.windowMs`, issues, 0, false)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {JsonValue} value - Raw maximum restart count.
|
|
245
|
+
* @param {string} key - Config key.
|
|
246
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
247
|
+
* @returns {number | undefined} Restart cap, or undefined for unlimited restarts.
|
|
248
|
+
*/
|
|
249
|
+
function normalizeMaxRestarts(value, key, issues) {
|
|
250
|
+
if (value === undefined || value === null) return undefined
|
|
251
|
+
|
|
252
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
253
|
+
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`})
|
|
254
|
+
|
|
255
|
+
return undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return value
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {JsonValue} value - Raw backoff factor.
|
|
263
|
+
* @param {string} key - Config key.
|
|
264
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
265
|
+
* @returns {number} Backoff multiplier (>= 1; 1 keeps a constant delay).
|
|
266
|
+
*/
|
|
267
|
+
function normalizeBackoffFactor(value, key, issues) {
|
|
268
|
+
const factor = normalizeNumber(value, key, issues, {default: 1})
|
|
269
|
+
|
|
270
|
+
if (factor < 1) {
|
|
271
|
+
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`})
|
|
272
|
+
|
|
273
|
+
return 1
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return factor
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {JsonValue} value - Raw memory supervision config.
|
|
281
|
+
* @param {string} key - Config key.
|
|
282
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
283
|
+
* @returns {MemoryConfig | undefined} Normalized memory config, or undefined when monitoring is off.
|
|
284
|
+
*/
|
|
285
|
+
function normalizeMemory(value, key, issues) {
|
|
286
|
+
if (value === undefined || value === null) return undefined
|
|
287
|
+
|
|
288
|
+
if (!isPlainObject(value)) {
|
|
289
|
+
issues.push({fix: `Set ${key} to a mapping with limitBytes (and optionally warnBytes, checkIntervalMs), or omit it to disable memory supervision.`, message: `${key} must be an object`})
|
|
290
|
+
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const limitBytes = normalizeNumber(value.limitBytes, `${key}.limitBytes`, issues, {default: 0})
|
|
295
|
+
const warnBytes = normalizeNumber(value.warnBytes, `${key}.warnBytes`, issues, {default: 0})
|
|
296
|
+
const checkIntervalMs = normalizeNumber(value.checkIntervalMs, `${key}.checkIntervalMs`, issues, {default: 5000})
|
|
297
|
+
|
|
298
|
+
if (!Number.isInteger(limitBytes) || limitBytes <= 0) {
|
|
299
|
+
issues.push({fix: `Set ${key}.limitBytes to a positive integer number of bytes, e.g. 536870912 (512 MiB).`, message: `${key}.limitBytes must be a positive integer`})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!Number.isInteger(warnBytes) || warnBytes < 0) {
|
|
303
|
+
issues.push({fix: `Set ${key}.warnBytes to a non-negative integer number of bytes below limitBytes, e.g. 402653184 (384 MiB).`, message: `${key}.warnBytes must be a non-negative integer`})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (checkIntervalMs <= 0) {
|
|
307
|
+
issues.push({fix: `Set ${key}.checkIntervalMs to a positive number of milliseconds, e.g. 5000.`, message: `${key}.checkIntervalMs must be a positive number`})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {checkIntervalMs, limitBytes, warnBytes}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {JsonValue} value - Raw lifecycle config.
|
|
315
|
+
* @param {string} key - Config key.
|
|
316
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
317
|
+
* @returns {LifecycleConfig} Normalized lifecycle hooks (no commands and a 0 drain by default).
|
|
318
|
+
*/
|
|
319
|
+
function normalizeLifecycle(value, key, issues) {
|
|
320
|
+
if (value === undefined || value === null) return {drainTimeoutMs: 0}
|
|
321
|
+
|
|
322
|
+
if (!isPlainObject(value)) {
|
|
323
|
+
issues.push({fix: `Set ${key} to a mapping with optional quietCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
|
|
324
|
+
|
|
325
|
+
return {drainTimeoutMs: 0}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const drainTimeoutMs = normalizeNumber(value.drainTimeoutMs, `${key}.drainTimeoutMs`, issues, {default: 0})
|
|
329
|
+
/** @type {LifecycleConfig} */
|
|
330
|
+
const lifecycle = {drainTimeoutMs: nonNegativeOrDefault(drainTimeoutMs, `${key}.drainTimeoutMs`, issues, 0, false)}
|
|
331
|
+
|
|
332
|
+
if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues)
|
|
333
|
+
if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues)
|
|
334
|
+
if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues)
|
|
335
|
+
|
|
336
|
+
if (lifecycle.drainCommand !== undefined && lifecycle.drainTimeoutMs <= 0) {
|
|
337
|
+
issues.push({fix: `Set ${key}.drainTimeoutMs to a positive number to bound ${key}.drainCommand; with 0 the drain step is skipped and the command never runs.`, message: `${key}.drainCommand requires a positive ${key}.drainTimeoutMs`})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return lifecycle
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @param {JsonValue} value - Raw stop signal.
|
|
345
|
+
* @param {string} key - Config key.
|
|
346
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
347
|
+
* @returns {string} The graceful-stop signal name (default "SIGTERM").
|
|
348
|
+
*/
|
|
349
|
+
function normalizeStopSignal(value, key, issues) {
|
|
350
|
+
if (value === undefined || value === null) return "SIGTERM"
|
|
351
|
+
|
|
352
|
+
if (typeof value === "string" && Object.prototype.hasOwnProperty.call(os.constants.signals, value)) {
|
|
353
|
+
return value
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
issues.push({fix: `Set ${key} to a signal name such as "SIGTERM", "SIGINT", or "SIGQUIT".`, message: `${key} must be a valid signal name`})
|
|
357
|
+
|
|
358
|
+
return "SIGTERM"
|
|
359
|
+
}
|
|
360
|
+
|
|
197
361
|
/**
|
|
198
362
|
* @param {JsonValue} value - Raw output retention value.
|
|
199
363
|
* @param {string} key - Config key.
|
|
@@ -212,6 +376,40 @@ function normalizeOutputLines(value, key, issues) {
|
|
|
212
376
|
return outputLines
|
|
213
377
|
}
|
|
214
378
|
|
|
379
|
+
/**
|
|
380
|
+
* @param {JsonValue} value - Raw replica count.
|
|
381
|
+
* @param {string} key - Config key.
|
|
382
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
383
|
+
* @returns {number} Number of replicas to run (default 1).
|
|
384
|
+
*/
|
|
385
|
+
function normalizeReplicas(value, key, issues) {
|
|
386
|
+
const replicas = normalizeNumber(value, key, issues, {default: 1})
|
|
387
|
+
|
|
388
|
+
if (!Number.isInteger(replicas) || replicas < 1) {
|
|
389
|
+
issues.push({fix: `Set ${key} to a positive integer number of replicas, e.g. 1 or 4.`, message: `${key} must be a positive integer`})
|
|
390
|
+
|
|
391
|
+
return 1
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return replicas
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {JsonValue} value - Raw boolean value.
|
|
399
|
+
* @param {string} key - Config key.
|
|
400
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
401
|
+
* @param {boolean} fallback - Default when unset.
|
|
402
|
+
* @returns {boolean} The boolean value, or the fallback when unset/invalid.
|
|
403
|
+
*/
|
|
404
|
+
function normalizeBoolean(value, key, issues, fallback) {
|
|
405
|
+
if (value === undefined || value === null) return fallback
|
|
406
|
+
if (typeof value === "boolean") return value
|
|
407
|
+
|
|
408
|
+
issues.push({fix: `Set ${key} to true or false.`, message: `${key} must be a boolean`})
|
|
409
|
+
|
|
410
|
+
return fallback
|
|
411
|
+
}
|
|
412
|
+
|
|
215
413
|
/**
|
|
216
414
|
* @param {Record<string, JsonValue>} source - Raw release retention config.
|
|
217
415
|
* @param {ConfigIssue[]} issues - Issue collector.
|
|
@@ -268,6 +466,26 @@ function normalizeSocketMode(value, key, issues) {
|
|
|
268
466
|
return undefined
|
|
269
467
|
}
|
|
270
468
|
|
|
469
|
+
/**
|
|
470
|
+
* @param {JsonValue} value - Raw owner/group value.
|
|
471
|
+
* @param {string} key - Config key.
|
|
472
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
473
|
+
* @returns {number | string | undefined} A numeric id, a name (resolved at bind time), or undefined when unset.
|
|
474
|
+
*/
|
|
475
|
+
function normalizeControlPrincipal(value, key, issues) {
|
|
476
|
+
if (value === undefined || value === null) return undefined
|
|
477
|
+
|
|
478
|
+
if (typeof value === "number") {
|
|
479
|
+
if (Number.isInteger(value) && value >= 0) return value
|
|
480
|
+
} else if (typeof value === "string" && value.length > 0) {
|
|
481
|
+
return value
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
issues.push({fix: `Set ${key} to a non-negative integer id or a user/group name, e.g. "deploy".`, message: `${key} must be a non-negative integer id or a name`})
|
|
485
|
+
|
|
486
|
+
return undefined
|
|
487
|
+
}
|
|
488
|
+
|
|
271
489
|
/**
|
|
272
490
|
* Validates cross-process rules: unique ids, exactly one proxied process, and proxied ports.
|
|
273
491
|
* @param {ProcessConfig[]} processes - Normalized processes.
|
|
@@ -285,6 +503,22 @@ function validateProcessSet(processes, issues) {
|
|
|
285
503
|
}
|
|
286
504
|
|
|
287
505
|
seenIds.add(processConfig.id)
|
|
506
|
+
|
|
507
|
+
if (processConfig.id.includes("#")) {
|
|
508
|
+
issues.push({fix: `Remove "#" from the process id "${processConfig.id}"; it is reserved for replica instance ids (e.g. "${processConfig.id.replace(/#/g, "")}#0").`, message: `Process id "${processConfig.id}" must not contain "#"`})
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (processConfig.replicas > 1 && (processConfig.policy !== "companion" || processConfig.port)) {
|
|
512
|
+
issues.push({fix: `Run replicas (${processConfig.replicas}) only on a companion process without a port; "${processConfig.id}" is ${processConfig.policy}${processConfig.port ? " with a port" : ""}.`, message: `Process "${processConfig.id}" can only set replicas > 1 on a companion process without a port`})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (processConfig.nonBlockingDrain && processConfig.policy !== "companion") {
|
|
516
|
+
issues.push({fix: `Set nonBlockingDrain only on a companion process; "${processConfig.id}" is ${processConfig.policy}.`, message: `Process "${processConfig.id}" can only set nonBlockingDrain on a companion process`})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (processConfig.lifecycle.stopCommand && processConfig.stopSignal !== "SIGTERM") {
|
|
520
|
+
issues.push({fix: `Drop the custom stopSignal "${processConfig.stopSignal}" or lifecycle.stopCommand from "${processConfig.id}": with a stopCommand set, Rollbridge runs it to stop the process instead of sending stopSignal, so the signal is never used.`, message: `Process "${processConfig.id}" sets both lifecycle.stopCommand and a custom stopSignal, but stopCommand replaces stopSignal`})
|
|
521
|
+
}
|
|
288
522
|
}
|
|
289
523
|
|
|
290
524
|
const proxiedProcesses = processes.filter((processConfig) => processConfig.policy === "proxied")
|