rollbridge 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +227 -5
- package/TODO.md +23 -19
- package/docs/cli.md +151 -0
- package/docs/config.md +128 -0
- package/docs/deploy-recipes.md +102 -0
- package/docs/troubleshooting.md +102 -0
- package/examples/rollbridge.service +48 -0
- package/package.json +20 -1
- package/src/cli.js +141 -2
- package/src/config.js +119 -9
- package/src/daemon.js +66 -6
- package/src/doctor.js +114 -0
- package/src/health.js +4 -0
- package/src/managed-process.js +17 -7
- package/src/release-group.js +35 -4
- package/test/config-validation.test.js +167 -0
- package/test/control-protocol.test.js +94 -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 +106 -0
- package/test/package-metadata.test.js +29 -0
- package/test/proxy.test.js +128 -0
- package/test/release-group.test.js +58 -0
- package/test/release-retention.test.js +107 -0
- package/test/rollbridge.test.js +79 -5
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 {
|
|
@@ -74,6 +74,10 @@ export default class RollbridgeDaemon {
|
|
|
74
74
|
resolve(undefined)
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
|
+
|
|
78
|
+
if (this.config.control.mode !== undefined) {
|
|
79
|
+
await fs.chmod(this.config.control.path, this.config.control.mode)
|
|
80
|
+
}
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/** @returns {Promise<void>} Removes a stale Unix socket before binding, or fails clearly when a daemon is alive. */
|
|
@@ -282,9 +286,7 @@ export default class RollbridgeDaemon {
|
|
|
282
286
|
await this.replaceSingletons(release)
|
|
283
287
|
|
|
284
288
|
if (previousRelease) {
|
|
285
|
-
|
|
286
|
-
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: previousRelease.releaseId})
|
|
287
|
-
})
|
|
289
|
+
void this.drainAndPrune(previousRelease)
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
return {
|
|
@@ -362,6 +364,7 @@ export default class RollbridgeDaemon {
|
|
|
362
364
|
cwd: nextDefinition.cwd,
|
|
363
365
|
env: nextDefinition.env,
|
|
364
366
|
logger: nextDefinition.logger,
|
|
367
|
+
outputLines: nextDefinition.outputLines,
|
|
365
368
|
restartDelayMs: nextDefinition.restartDelayMs,
|
|
366
369
|
shouldRestart: nextDefinition.shouldRestart,
|
|
367
370
|
stopTimeoutMs: nextDefinition.stopTimeoutMs
|
|
@@ -402,6 +405,31 @@ export default class RollbridgeDaemon {
|
|
|
402
405
|
if (release === this.activeRelease) this.activeRelease = undefined
|
|
403
406
|
|
|
404
407
|
await release.stop()
|
|
408
|
+
this.pruneStoppedReleases()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
413
|
+
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
414
|
+
* @returns {Promise<void>} Resolves once drained, stopped, and pruned.
|
|
415
|
+
*/
|
|
416
|
+
async drainAndPrune(release) {
|
|
417
|
+
try {
|
|
418
|
+
await release.drainAndStop(this.config.proxy.drainTimeoutMs)
|
|
419
|
+
} catch (error) {
|
|
420
|
+
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
|
|
421
|
+
} finally {
|
|
422
|
+
this.pruneStoppedReleases()
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @returns {void} Removes stopped releases beyond the retention policy. */
|
|
427
|
+
pruneStoppedReleases() {
|
|
428
|
+
const statuses = [...this.releases.values()].map((release) => release.status())
|
|
429
|
+
|
|
430
|
+
for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
|
|
431
|
+
this.releases.delete(releaseId)
|
|
432
|
+
}
|
|
405
433
|
}
|
|
406
434
|
|
|
407
435
|
/** @returns {Promise<void>} Stops proxy, control socket, and child processes. */
|
|
@@ -441,7 +469,8 @@ export default class RollbridgeDaemon {
|
|
|
441
469
|
control: {...this.config.control},
|
|
442
470
|
proxy: {
|
|
443
471
|
host: this.config.proxy.host,
|
|
444
|
-
port: this.proxyPort ?? this.config.proxy.port
|
|
472
|
+
port: this.proxyPort ?? this.config.proxy.port,
|
|
473
|
+
upstreamHost: this.config.proxy.upstreamHost
|
|
445
474
|
},
|
|
446
475
|
releases: [...this.releases.values()].map((release) => release.status()),
|
|
447
476
|
services: [...this.services.entries()].map(([id, processInstance]) => ({
|
|
@@ -480,6 +509,37 @@ function requiredString(value, key) {
|
|
|
480
509
|
return value
|
|
481
510
|
}
|
|
482
511
|
|
|
512
|
+
/**
|
|
513
|
+
* @typedef {{releaseId: string, state: string, stoppedAt: string | undefined}} PrunableRelease
|
|
514
|
+
*/
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Selects stopped releases to prune by the retention policy, keeping the most recent.
|
|
518
|
+
* @param {PrunableRelease[]} releases - Status of all tracked releases, in deploy order (oldest first).
|
|
519
|
+
* @param {import("./config.js").ReleaseRetentionConfig} policy - Retention policy.
|
|
520
|
+
* @param {number} now - Current epoch milliseconds.
|
|
521
|
+
* @returns {string[]} Release ids to remove.
|
|
522
|
+
*/
|
|
523
|
+
export function releasesToPrune(releases, policy, now) {
|
|
524
|
+
const stopped = releases
|
|
525
|
+
.filter((release) => release.state === "stopped")
|
|
526
|
+
.map((release, index) => ({deployOrder: index, releaseId: release.releaseId, stoppedAtMs: release.stoppedAt ? Date.parse(release.stoppedAt) : 0}))
|
|
527
|
+
// Most recent first; ties (same stoppedAt millisecond) prefer the later-deployed release.
|
|
528
|
+
.sort((first, second) => second.stoppedAtMs - first.stoppedAtMs || second.deployOrder - first.deployOrder)
|
|
529
|
+
|
|
530
|
+
/** @type {string[]} */
|
|
531
|
+
const remove = []
|
|
532
|
+
|
|
533
|
+
stopped.forEach((release, index) => {
|
|
534
|
+
const beyondKeep = index >= policy.keep
|
|
535
|
+
const tooOld = policy.maxAgeMs > 0 && release.stoppedAtMs > 0 && now - release.stoppedAtMs > policy.maxAgeMs
|
|
536
|
+
|
|
537
|
+
if (beyondKeep || tooOld) remove.push(release.releaseId)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
return remove
|
|
541
|
+
}
|
|
542
|
+
|
|
483
543
|
/**
|
|
484
544
|
* @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null}} ControlSocketInspection
|
|
485
545
|
*/
|
|
@@ -507,7 +567,7 @@ function controlSocketBusyMessage(socketPath, inspection) {
|
|
|
507
567
|
* @param {number} [timeoutMs] - How long to wait for a status response before treating the socket as busy.
|
|
508
568
|
* @returns {Promise<ControlSocketInspection>} Whether the socket is live and, when it is Rollbridge, its identity.
|
|
509
569
|
*/
|
|
510
|
-
async function inspectControlSocket(socketPath, timeoutMs = 1000) {
|
|
570
|
+
export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
|
|
511
571
|
return await new Promise((resolve, reject) => {
|
|
512
572
|
const socket = net.createConnection(socketPath)
|
|
513
573
|
let buffer = ""
|
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, 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, 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 {
|
|
@@ -20,11 +20,12 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
20
20
|
* @param {Record<string, string | undefined>} args.env - Environment.
|
|
21
21
|
* @param {string} args.id - Process id.
|
|
22
22
|
* @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger callback.
|
|
23
|
+
* @param {number} args.outputLines - Recent stdout/stderr lines to retain and report.
|
|
23
24
|
* @param {number} args.restartDelayMs - Restart delay.
|
|
24
25
|
* @param {() => boolean} args.shouldRestart - Restart policy callback.
|
|
25
26
|
* @param {number} args.stopTimeoutMs - Stop timeout.
|
|
26
27
|
*/
|
|
27
|
-
constructor({command, cwd, env, id, logger, restartDelayMs, shouldRestart, stopTimeoutMs}) {
|
|
28
|
+
constructor({command, cwd, env, id, logger, outputLines, restartDelayMs, shouldRestart, stopTimeoutMs}) {
|
|
28
29
|
super()
|
|
29
30
|
|
|
30
31
|
this.command = command
|
|
@@ -32,11 +33,14 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
32
33
|
this.env = env
|
|
33
34
|
this.id = id
|
|
34
35
|
this.logger = logger
|
|
36
|
+
this.outputLines = outputLines
|
|
35
37
|
this.restartDelayMs = restartDelayMs
|
|
36
38
|
this.shouldRestart = shouldRestart
|
|
37
39
|
this.stopTimeoutMs = stopTimeoutMs
|
|
38
40
|
this.state = /** @type {ManagedProcessState} */ ("stopped")
|
|
39
41
|
this.logs = /** @type {ManagedProcessLog[]} */ ([])
|
|
42
|
+
this.restarts = 0
|
|
43
|
+
this.startedAtMs = /** @type {number | undefined} */ (undefined)
|
|
40
44
|
this.intentionalStop = false
|
|
41
45
|
this.restartTimer = undefined
|
|
42
46
|
this.child = undefined
|
|
@@ -75,6 +79,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
75
79
|
|
|
76
80
|
child.once("spawn", () => {
|
|
77
81
|
this.state = "running"
|
|
82
|
+
this.startedAtMs = Date.now()
|
|
78
83
|
this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
|
|
79
84
|
this.emit("started")
|
|
80
85
|
resolve(undefined)
|
|
@@ -100,6 +105,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
100
105
|
this.cwd = definition.cwd
|
|
101
106
|
this.env = definition.env
|
|
102
107
|
this.logger = definition.logger
|
|
108
|
+
this.outputLines = definition.outputLines
|
|
103
109
|
this.restartDelayMs = definition.restartDelayMs
|
|
104
110
|
this.shouldRestart = definition.shouldRestart
|
|
105
111
|
this.stopTimeoutMs = definition.stopTimeoutMs
|
|
@@ -116,8 +122,8 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
116
122
|
|
|
117
123
|
this.logs.push({at: new Date().toISOString(), line, stream})
|
|
118
124
|
|
|
119
|
-
if (this.logs.length >
|
|
120
|
-
this.logs.splice(0, this.logs.length -
|
|
125
|
+
if (this.logs.length > this.outputLines) {
|
|
126
|
+
this.logs.splice(0, this.logs.length - this.outputLines)
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
}
|
|
@@ -142,6 +148,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
142
148
|
if (!wasIntentional && this.shouldRestart()) {
|
|
143
149
|
this.restartTimer = setTimeout(() => {
|
|
144
150
|
this.restartTimer = undefined
|
|
151
|
+
this.restarts += 1
|
|
145
152
|
this.start().catch((error) => {
|
|
146
153
|
this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
|
|
147
154
|
})
|
|
@@ -224,9 +231,12 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
224
231
|
exitCode: this.exitCode,
|
|
225
232
|
exitSignal: this.exitSignal,
|
|
226
233
|
id: this.id,
|
|
227
|
-
logs: this.logs.slice(-
|
|
234
|
+
logs: this.logs.slice(-this.outputLines),
|
|
228
235
|
pid: this.pid,
|
|
229
|
-
|
|
236
|
+
restarts: this.restarts,
|
|
237
|
+
startedAt: this.startedAtMs === undefined ? undefined : new Date(this.startedAtMs).toISOString(),
|
|
238
|
+
state: this.state,
|
|
239
|
+
uptimeMs: this.state === "running" && this.startedAtMs !== undefined ? Date.now() - this.startedAtMs : undefined
|
|
230
240
|
}
|
|
231
241
|
}
|
|
232
242
|
}
|
package/src/release-group.js
CHANGED
|
@@ -67,18 +67,46 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
67
67
|
if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
|
|
68
68
|
await waitForHealth({
|
|
69
69
|
health: processConfig.health,
|
|
70
|
-
host: this.config.proxy.
|
|
70
|
+
host: this.config.proxy.upstreamHost,
|
|
71
71
|
port: this.ports[processConfig.id]
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
} catch (error) {
|
|
76
76
|
this.state = "failed"
|
|
77
|
+
this.logStartupFailure(error instanceof Error ? error : String(error))
|
|
77
78
|
await this.stop()
|
|
78
79
|
throw error
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Logs process diagnostics before failed startup cleanup stops and removes the release processes.
|
|
85
|
+
* @param {Error | string} error - Startup failure.
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
logStartupFailure(error) {
|
|
89
|
+
this.logger("release startup failed", {
|
|
90
|
+
error: error instanceof Error ? error.message : error,
|
|
91
|
+
releaseId: this.releaseId
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
for (const processInstance of this.processes.values()) {
|
|
95
|
+
const status = processInstance.status()
|
|
96
|
+
|
|
97
|
+
this.logger("release startup process status", {
|
|
98
|
+
command: status.command,
|
|
99
|
+
exitCode: status.exitCode ?? null,
|
|
100
|
+
exitSignal: status.exitSignal ?? null,
|
|
101
|
+
logs: status.logs,
|
|
102
|
+
pid: status.pid ?? null,
|
|
103
|
+
processId: status.id,
|
|
104
|
+
releaseId: this.releaseId,
|
|
105
|
+
state: status.state
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/**
|
|
83
111
|
* Starts companions before the proxied process so release-local dependencies are available before health checks.
|
|
84
112
|
* @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
|
|
@@ -112,7 +140,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
this.ports[processConfig.id] = await findAvailablePort({
|
|
115
|
-
host: this.config.proxy.
|
|
143
|
+
host: this.config.proxy.upstreamHost,
|
|
116
144
|
range: processConfig.port,
|
|
117
145
|
usedPorts
|
|
118
146
|
})
|
|
@@ -141,6 +169,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
141
169
|
env: processEnv,
|
|
142
170
|
id: processConfig.id,
|
|
143
171
|
logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
|
|
172
|
+
outputLines: processConfig.outputLines,
|
|
144
173
|
restartDelayMs: processConfig.restartDelayMs,
|
|
145
174
|
shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
|
|
146
175
|
stopTimeoutMs: processConfig.gracefulStopMs
|
|
@@ -179,12 +208,14 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
179
208
|
contextForProcess(processConfig) {
|
|
180
209
|
return {
|
|
181
210
|
application: this.config.application,
|
|
211
|
+
env: {...process.env},
|
|
182
212
|
port: this.ports[processConfig.id],
|
|
183
213
|
ports: this.ports,
|
|
184
214
|
processId: processConfig.id,
|
|
185
215
|
proxy: {
|
|
186
216
|
host: this.config.proxy.host,
|
|
187
|
-
port: this.config.proxy.port
|
|
217
|
+
port: this.config.proxy.port,
|
|
218
|
+
upstreamHost: this.config.proxy.upstreamHost
|
|
188
219
|
},
|
|
189
220
|
releaseId: this.releaseId,
|
|
190
221
|
releasePath: this.releasePath,
|
|
@@ -209,7 +240,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
209
240
|
|
|
210
241
|
return {
|
|
211
242
|
process: processInstance,
|
|
212
|
-
target: `http://${this.config.proxy.
|
|
243
|
+
target: `http://${this.config.proxy.upstreamHost}:${port}`
|
|
213
244
|
}
|
|
214
245
|
}
|
|
215
246
|
|
|
@@ -55,6 +55,139 @@ 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
|
+
|
|
73
|
+
test("validateConfig defaults outputLines and accepts a positive override", () => {
|
|
74
|
+
const {config, issues} = validateConfig({
|
|
75
|
+
application: "demo",
|
|
76
|
+
control: {path: "/tmp/demo.sock"},
|
|
77
|
+
processes: [
|
|
78
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
|
|
79
|
+
{command: "run worker", id: "worker", outputLines: 5, policy: "companion"}
|
|
80
|
+
],
|
|
81
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(issues, [])
|
|
85
|
+
assert.equal(config.processes[0].outputLines, 50)
|
|
86
|
+
assert.equal(config.processes[1].outputLines, 5)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
|
|
90
|
+
const {issues} = validateConfig({
|
|
91
|
+
application: "demo",
|
|
92
|
+
control: {path: "/tmp/demo.sock"},
|
|
93
|
+
processes: [
|
|
94
|
+
{command: "run web", id: "web", outputLines: 0, policy: "proxied", port: {from: 18000, to: 18099}}
|
|
95
|
+
],
|
|
96
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const issue = issues.find((candidate) => candidate.message === "processes[0].outputLines must be a positive integer")
|
|
100
|
+
|
|
101
|
+
assert.ok(issue, `expected an outputLines issue in ${JSON.stringify(issues.map((candidate) => candidate.message))}`)
|
|
102
|
+
assert.match(issue.fix, /positive integer/)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("validateConfig parses control.mode, defaults it to unset, and rejects invalid modes", () => {
|
|
106
|
+
/**
|
|
107
|
+
* @param {import("../src/json.js").JsonValue} control - Control config under test.
|
|
108
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
109
|
+
*/
|
|
110
|
+
const validateControl = (control) => validateConfig({
|
|
111
|
+
application: "demo",
|
|
112
|
+
control,
|
|
113
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
114
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const parsed = validateControl({mode: "660", path: "/tmp/demo.sock"})
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(parsed.issues, [])
|
|
120
|
+
assert.equal(parsed.config.control.mode, 0o660)
|
|
121
|
+
|
|
122
|
+
// Minimal octal strings are accepted, matching the numeric boundary (e.g. 0).
|
|
123
|
+
const minimal = validateControl({mode: "0", path: "/tmp/demo.sock"})
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(minimal.issues, [])
|
|
126
|
+
assert.equal(minimal.config.control.mode, 0)
|
|
127
|
+
|
|
128
|
+
assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.mode, undefined)
|
|
129
|
+
|
|
130
|
+
const invalid = validateControl({mode: "abc", path: "/tmp/demo.sock"})
|
|
131
|
+
|
|
132
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
|
|
136
|
+
/**
|
|
137
|
+
* @param {import("../src/json.js").JsonValue} health - Health config under test, or undefined to omit it.
|
|
138
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
139
|
+
*/
|
|
140
|
+
const validateHealth = (health) => validateConfig({
|
|
141
|
+
application: "demo",
|
|
142
|
+
control: {path: "/tmp/demo.sock"},
|
|
143
|
+
processes: [{command: "run web", health, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
144
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const defaulted = validateHealth({path: "/ping"})
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(defaulted.issues, [])
|
|
150
|
+
assert.equal(defaulted.config.processes[0].health?.startDelayMs, 0)
|
|
151
|
+
|
|
152
|
+
const custom = validateHealth({path: "/ping", startDelayMs: 2000})
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(custom.issues, [])
|
|
155
|
+
assert.equal(custom.config.processes[0].health?.startDelayMs, 2000)
|
|
156
|
+
|
|
157
|
+
const negative = validateHealth({path: "/ping", startDelayMs: -1})
|
|
158
|
+
|
|
159
|
+
assert.ok(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number"))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
|
|
163
|
+
/**
|
|
164
|
+
* @param {import("../src/json.js").JsonValue} releaseRetention - Retention config under test, or undefined.
|
|
165
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
166
|
+
*/
|
|
167
|
+
const validateRetention = (releaseRetention) => validateConfig({
|
|
168
|
+
application: "demo",
|
|
169
|
+
control: {path: "/tmp/demo.sock"},
|
|
170
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
171
|
+
proxy: {host: "127.0.0.1", port: 8182},
|
|
172
|
+
releaseRetention
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const defaulted = validateRetention(undefined)
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(defaulted.issues, [])
|
|
178
|
+
assert.deepEqual(defaulted.config.releaseRetention, {keep: 10, maxAgeMs: 0})
|
|
179
|
+
|
|
180
|
+
const custom = validateRetention({keep: 3, maxAgeMs: 60000})
|
|
181
|
+
|
|
182
|
+
assert.deepEqual(custom.issues, [])
|
|
183
|
+
assert.deepEqual(custom.config.releaseRetention, {keep: 3, maxAgeMs: 60000})
|
|
184
|
+
|
|
185
|
+
const invalid = validateRetention({keep: -1, maxAgeMs: -5})
|
|
186
|
+
|
|
187
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer"))
|
|
188
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
|
|
189
|
+
})
|
|
190
|
+
|
|
58
191
|
test("normalizeConfig throws an aggregated error listing every issue", () => {
|
|
59
192
|
assert.throws(
|
|
60
193
|
() => normalizeConfig({
|
|
@@ -116,6 +249,40 @@ test("validate CLI command accepts a valid config without setting a failure exit
|
|
|
116
249
|
}
|
|
117
250
|
})
|
|
118
251
|
|
|
252
|
+
test("validate --json emits a machine-readable result", async () => {
|
|
253
|
+
const validPath = await writeConfig({
|
|
254
|
+
application: "demo",
|
|
255
|
+
control: {path: "/tmp/rollbridge-json-valid.sock"},
|
|
256
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
257
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
258
|
+
})
|
|
259
|
+
const invalidPath = await writeConfig({
|
|
260
|
+
application: "demo",
|
|
261
|
+
processes: [{command: "run web", id: "web", policy: "proxied"}],
|
|
262
|
+
proxy: {port: 8182}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
|
|
267
|
+
|
|
268
|
+
assert.equal(valid.valid, true)
|
|
269
|
+
assert.deepEqual(valid.issues, [])
|
|
270
|
+
assert.equal(valid.config.processes, 1)
|
|
271
|
+
assert.notEqual(process.exitCode, 1)
|
|
272
|
+
|
|
273
|
+
const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
|
|
274
|
+
|
|
275
|
+
assert.equal(invalid.valid, false)
|
|
276
|
+
assert.equal(invalid.config, null)
|
|
277
|
+
assert.ok(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message)))
|
|
278
|
+
assert.equal(process.exitCode, 1)
|
|
279
|
+
} finally {
|
|
280
|
+
process.exitCode = 0
|
|
281
|
+
await fs.rm(path.dirname(validPath), {force: true, recursive: true})
|
|
282
|
+
await fs.rm(path.dirname(invalidPath), {force: true, recursive: true})
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
119
286
|
/**
|
|
120
287
|
* @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config object.
|
|
121
288
|
* @returns {Promise<string>} Path to the written config module.
|