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/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
- previousRelease.drainAndStop(this.config.proxy.drainTimeoutMs).catch((error) => {
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"
@@ -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 > 200) {
120
- this.logs.splice(0, this.logs.length - 200)
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(-20),
234
+ logs: this.logs.slice(-this.outputLines),
228
235
  pid: this.pid,
229
- state: this.state
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
  }
@@ -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.host,
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.host,
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.host}:${port}`
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.