rollbridge 0.1.1 → 0.1.2

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 CHANGED
@@ -55,7 +55,8 @@ export default {
55
55
  id: "background-jobs-worker",
56
56
  policy: "companion",
57
57
  cwd: "{{releasePath}}",
58
- command: "npx velocious background-jobs-worker"
58
+ command: "npx velocious background-jobs-worker",
59
+ outputLines: 200
59
60
  },
60
61
  {
61
62
  id: "background-jobs-main",
@@ -75,6 +76,15 @@ export default {
75
76
  }
76
77
  ```
77
78
 
79
+ Each process retains its most recent stdout/stderr lines and reports them in
80
+ `status`. Set `outputLines` (a positive integer, default 50) per process to keep
81
+ more or fewer lines for chatty or quiet processes.
82
+
83
+ Set `control.mode` to an octal permission string (for example `"660"`) to
84
+ chmod the control socket after it binds. This restricts which users can send
85
+ control commands — useful when several deploy users share a group. When unset,
86
+ the socket keeps the default permissions from the daemon's umask.
87
+
78
88
  A function export receives no arguments and lets you build the config at load
79
89
  time:
80
90
 
@@ -90,6 +100,19 @@ export default () => ({
90
100
  })
91
101
  ```
92
102
 
103
+ ### Template variables
104
+
105
+ A process `command`, `cwd`, and `env` values support `{{...}}` placeholders
106
+ rendered when the process starts:
107
+
108
+ - `{{releasePath}}`, `{{releaseId}}`, `{{revision}}`, `{{application}}`, `{{processId}}`
109
+ - `{{port}}` — the port allocated to this process; `{{ports.<id>}}` — another process's allocated port
110
+ - `{{proxy.host}}`, `{{proxy.port}}`
111
+ - `{{env.<NAME>}}` — a variable from the daemon's own environment, e.g. `{{env.HOME}}`
112
+
113
+ Referencing a placeholder with no value (including an unset `{{env.<NAME>}}`)
114
+ fails the process start with a clear error, so typos surface immediately.
115
+
93
116
  Production-ready examples live in `examples/`, including
94
117
  `examples/tensorbuzz.com.js` for the current TensorBuzz backend deployment.
95
118
 
@@ -187,6 +210,38 @@ location / {
187
210
  }
188
211
  ```
189
212
 
213
+ ## Running under systemd
214
+
215
+ Run the long-lived daemon as a systemd service so it starts on boot and is
216
+ restarted if it crashes. A ready-to-edit unit lives at
217
+ `examples/rollbridge.service`:
218
+
219
+ ```bash
220
+ sudo cp examples/rollbridge.service /etc/systemd/system/rollbridge.service
221
+ # edit User/Group, WorkingDirectory, the ExecStart path, and --config
222
+ sudo systemctl daemon-reload
223
+ sudo systemctl enable --now rollbridge
224
+ sudo systemctl status rollbridge
225
+ ```
226
+
227
+ The unit runs `rollbridge daemon --config <stable-config>` in the foreground,
228
+ so its output goes to the journal (`journalctl -u rollbridge`). Key directives:
229
+
230
+ - `KillMode=mixed` / `KillSignal=SIGTERM`: Rollbridge stops its own managed
231
+ child process groups on `SIGTERM`, so systemd signals only the daemon and
232
+ lets it shut down gracefully before escalating to `SIGKILL`.
233
+ - `TimeoutStopSec`: give the daemon time to stop its managed processes; size it
234
+ above the largest process `gracefulStopMs` (the daemon `SIGKILL`s stragglers
235
+ after that). Note that `systemctl stop`/reboot stops processes but does **not**
236
+ drain HTTP/WebSocket connections — connection draining happens only during
237
+ `rollbridge deploy` release transitions.
238
+
239
+ The daemon is long-lived and survives deploys. **Deploy with
240
+ `rollbridge deploy` (or `rollbridge deploy --ensure-daemon`), not
241
+ `systemctl restart`** — pointing `--config` at a stable, daemon-wide file while
242
+ release paths are passed per deploy. Use `command -v rollbridge` to find the
243
+ absolute CLI path for `ExecStart`.
244
+
190
245
  ## Deployment Notes
191
246
 
192
247
  Run migrations before `rollbridge deploy`, and keep migrations backwards-compatible while old and new web releases overlap. For stable local brokers such as Velocious Beacon or `background-jobs-main`, use `service` when the process should survive deploys and restart from the latest successful release if it crashes.
package/TODO.md CHANGED
@@ -65,20 +65,21 @@ This roadmap tracks planned Rollbridge features and documentation. Rollbridge sh
65
65
 
66
66
  ## Minor Features
67
67
 
68
- - [ ] Add socket permission and socket owner/group options for shared deploy users.
68
+ - [x] Add a control-socket permission option (`control.mode`) for shared deploy users.
69
+ - [ ] Add control-socket owner/group options for shared deploy users (needs name-to-id resolution).
69
70
  - [x] Make stale control socket diagnostics clearer when another daemon is still alive.
70
71
  - [ ] Add old-release cleanup policies by age, count, and stopped state.
71
72
  - [x] Add port allocation diagnostics when a range is exhausted.
72
73
  - [ ] Add optional startup command timeout before health checks begin.
73
- - [ ] Add process output retention config instead of a fixed recent-log count.
74
- - [ ] Add environment variable interpolation from the daemon environment.
74
+ - [x] Add process output retention config instead of a fixed recent-log count.
75
+ - [x] Add environment variable interpolation from the daemon environment.
75
76
  - [x] Add `--config` default lookup resolving to `rollbridge.js` when no path is given.
76
77
  - [ ] Add shell completion generation for common shells.
77
78
  - [ ] Add npm package metadata such as repository, license, bugs, and homepage.
78
- - [ ] Add systemd service examples for the Rollbridge daemon.
79
- - [ ] Add tests for malformed control socket JSON and unknown control commands.
79
+ - [x] Add systemd service examples for the Rollbridge daemon.
80
+ - [x] Add tests for malformed control socket JSON and unknown control commands.
80
81
  - [ ] Add tests for duplicate IDs and singleton replacement failure behavior.
81
- - [ ] Add tests for proxy behavior when the active release exits unexpectedly.
82
+ - [x] Add tests for proxy behavior when the active release exits unexpectedly.
82
83
 
83
84
  ## Documentation TODO
84
85
 
@@ -0,0 +1,48 @@
1
+ # Example systemd unit for the long-running Rollbridge daemon.
2
+ #
3
+ # Install:
4
+ # sudo cp examples/rollbridge.service /etc/systemd/system/rollbridge.service
5
+ # # edit User/Group, WorkingDirectory, ExecStart path, and --config below
6
+ # sudo systemctl daemon-reload
7
+ # sudo systemctl enable --now rollbridge
8
+ #
9
+ # The daemon is long-lived and survives deploys. Deploys go through
10
+ # `rollbridge deploy` (optionally `--ensure-daemon`), NOT `systemctl restart`.
11
+ # Point --config at a stable, daemon-wide config file (release paths are passed
12
+ # per deploy with `rollbridge deploy --release-path ...`).
13
+ #
14
+ # Find the absolute path to the installed CLI for ExecStart with:
15
+ # command -v rollbridge
16
+
17
+ [Unit]
18
+ Description=Rollbridge zero-downtime process supervisor
19
+ After=network.target
20
+
21
+ [Service]
22
+ Type=simple
23
+ User=deploy
24
+ Group=deploy
25
+ WorkingDirectory=/srv/ticket-server
26
+ ExecStart=/usr/local/bin/rollbridge daemon --config /etc/rollbridge/rollbridge.js
27
+
28
+ # Optional: variables here are visible to the JS config (process.env) and to
29
+ # `{{env.NAME}}` templates in process commands.
30
+ # EnvironmentFile=/etc/rollbridge/ticket-server.env
31
+
32
+ Restart=on-failure
33
+ RestartSec=2
34
+
35
+ # Rollbridge supervises its own child process groups and stops them on SIGTERM,
36
+ # so send SIGTERM to the daemon only and let it drain/stop releases itself;
37
+ # systemd SIGKILLs anything still alive after the timeout.
38
+ KillMode=mixed
39
+ KillSignal=SIGTERM
40
+
41
+ # Allow time for the daemon to stop its managed processes. Size this above the
42
+ # largest process gracefulStopMs in your config (the daemon SIGKILLs stragglers
43
+ # after that). Note: `systemctl stop` does not drain HTTP/WebSocket connections;
44
+ # draining happens only during `rollbridge deploy` release transitions.
45
+ TimeoutStopSec=120
46
+
47
+ [Install]
48
+ WantedBy=multi-user.target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -9,8 +9,8 @@ import {pathToFileURL} from "node:url"
9
9
  * @typedef {{from: number, to: number}} PortRange
10
10
  * @typedef {{path: string, timeoutMs: number, intervalMs: number}} HealthConfig
11
11
  * @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
12
- * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, policy: ProcessPolicy, port?: PortRange, restartDelayMs: number, command: string}} ProcessConfig
13
- * @typedef {{path: string}} ControlConfig
12
+ * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, outputLines: number, policy: ProcessPolicy, port?: PortRange, restartDelayMs: number, command: string}} ProcessConfig
13
+ * @typedef {{mode?: number, path: string}} ControlConfig
14
14
  * @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number}} ProxyConfig
15
15
  * @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig}} RollbridgeConfig
16
16
  * @typedef {{fix: string, message: string}} ConfigIssue
@@ -123,6 +123,7 @@ export function validateConfig(rawConfig, configPath = process.cwd()) {
123
123
  const processesSource = arrayAt(source.processes, "processes", issues)
124
124
  const proxy = normalizeProxy(proxySource, issues)
125
125
  const control = {
126
+ mode: normalizeSocketMode(controlSource.mode, "control.mode", issues),
126
127
  path: normalizeString(controlSource.path, "control.path", issues, {default: `/tmp/rollbridge-${application}.sock`})
127
128
  }
128
129
  const processes = processesSource.map((processSource, index) => normalizeProcess(processSource, index, proxy, issues))
@@ -159,7 +160,7 @@ function normalizeProcess(value, index, proxy, issues) {
159
160
  if (!isPlainObject(value)) {
160
161
  issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
161
162
 
162
- return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", policy: "companion", port: undefined, restartDelayMs: 1000}
163
+ return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", outputLines: 50, policy: "companion", port: undefined, restartDelayMs: 1000}
163
164
  }
164
165
 
165
166
  const source = value
@@ -171,12 +172,54 @@ function normalizeProcess(value, index, proxy, issues) {
171
172
  gracefulStopMs: normalizeNumber(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, issues, {default: proxy.forceStopTimeoutMs}),
172
173
  health: normalizeHealth(source.health, `processes[${index}].health`, proxy, issues),
173
174
  id: normalizeString(source.id, `processes[${index}].id`, issues),
175
+ outputLines: normalizeOutputLines(source.outputLines, `processes[${index}].outputLines`, issues),
174
176
  policy: normalizePolicy(source.policy, `processes[${index}].policy`, issues),
175
177
  port: normalizePortRange(source.port, `processes[${index}].port`, issues),
176
178
  restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000})
177
179
  }
178
180
  }
179
181
 
182
+ /**
183
+ * @param {JsonValue} value - Raw output retention value.
184
+ * @param {string} key - Config key.
185
+ * @param {ConfigIssue[]} issues - Issue collector.
186
+ * @returns {number} Recent output lines to retain and report (default 50).
187
+ */
188
+ function normalizeOutputLines(value, key, issues) {
189
+ const outputLines = normalizeNumber(value, key, issues, {default: 50})
190
+
191
+ if (!Number.isInteger(outputLines) || outputLines < 1) {
192
+ issues.push({fix: `Set ${key} to a positive integer number of lines, e.g. 50.`, message: `${key} must be a positive integer`})
193
+
194
+ return 50
195
+ }
196
+
197
+ return outputLines
198
+ }
199
+
200
+ /**
201
+ * @param {JsonValue} value - Raw socket permission mode.
202
+ * @param {string} key - Config key.
203
+ * @param {ConfigIssue[]} issues - Issue collector.
204
+ * @returns {number | undefined} File mode bits (0 to 0o777), or undefined when unset.
205
+ */
206
+ function normalizeSocketMode(value, key, issues) {
207
+ if (value === undefined || value === null) return undefined
208
+
209
+ if (typeof value === "number") {
210
+ if (Number.isInteger(value) && value >= 0 && value <= 0o777) return value
211
+ } else if (typeof value === "string") {
212
+ const cleaned = value.startsWith("0o") ? value.slice(2) : value
213
+ const mode = /^[0-7]{1,4}$/.test(cleaned) ? parseInt(cleaned, 8) : Number.NaN
214
+
215
+ if (Number.isInteger(mode) && mode >= 0 && mode <= 0o777) return mode
216
+ }
217
+
218
+ issues.push({fix: `Set ${key} to an octal permission string like "660" (or an octal number such as 0o660).`, message: `${key} must be an octal file mode between 0 and 0o777`})
219
+
220
+ return undefined
221
+ }
222
+
180
223
  /**
181
224
  * Validates cross-process rules: unique ids, exactly one proxied process, and proxied ports.
182
225
  * @param {ProcessConfig[]} processes - Normalized processes.
package/src/daemon.js CHANGED
@@ -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. */
@@ -362,6 +366,7 @@ export default class RollbridgeDaemon {
362
366
  cwd: nextDefinition.cwd,
363
367
  env: nextDefinition.env,
364
368
  logger: nextDefinition.logger,
369
+ outputLines: nextDefinition.outputLines,
365
370
  restartDelayMs: nextDefinition.restartDelayMs,
366
371
  shouldRestart: nextDefinition.shouldRestart,
367
372
  stopTimeoutMs: nextDefinition.stopTimeoutMs
@@ -8,7 +8,7 @@ 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
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
12
  * @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, state: ManagedProcessState}} ManagedProcessStatus
13
13
  */
14
14
 
@@ -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,6 +33,7 @@ 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
@@ -100,6 +102,7 @@ export default class ManagedProcess extends EventEmitter {
100
102
  this.cwd = definition.cwd
101
103
  this.env = definition.env
102
104
  this.logger = definition.logger
105
+ this.outputLines = definition.outputLines
103
106
  this.restartDelayMs = definition.restartDelayMs
104
107
  this.shouldRestart = definition.shouldRestart
105
108
  this.stopTimeoutMs = definition.stopTimeoutMs
@@ -116,8 +119,8 @@ export default class ManagedProcess extends EventEmitter {
116
119
 
117
120
  this.logs.push({at: new Date().toISOString(), line, stream})
118
121
 
119
- if (this.logs.length > 200) {
120
- this.logs.splice(0, this.logs.length - 200)
122
+ if (this.logs.length > this.outputLines) {
123
+ this.logs.splice(0, this.logs.length - this.outputLines)
121
124
  }
122
125
  }
123
126
  }
@@ -224,7 +227,7 @@ export default class ManagedProcess extends EventEmitter {
224
227
  exitCode: this.exitCode,
225
228
  exitSignal: this.exitSignal,
226
229
  id: this.id,
227
- logs: this.logs.slice(-20),
230
+ logs: this.logs.slice(-this.outputLines),
228
231
  pid: this.pid,
229
232
  state: this.state
230
233
  }
@@ -141,6 +141,7 @@ export default class ReleaseGroup extends EventEmitter {
141
141
  env: processEnv,
142
142
  id: processConfig.id,
143
143
  logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
144
+ outputLines: processConfig.outputLines,
144
145
  restartDelayMs: processConfig.restartDelayMs,
145
146
  shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
146
147
  stopTimeoutMs: processConfig.gracefulStopMs
@@ -179,6 +180,7 @@ export default class ReleaseGroup extends EventEmitter {
179
180
  contextForProcess(processConfig) {
180
181
  return {
181
182
  application: this.config.application,
183
+ env: {...process.env},
182
184
  port: this.ports[processConfig.id],
183
185
  ports: this.ports,
184
186
  processId: processConfig.id,
@@ -55,6 +55,68 @@ 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 outputLines and accepts a positive override", () => {
59
+ const {config, issues} = validateConfig({
60
+ application: "demo",
61
+ control: {path: "/tmp/demo.sock"},
62
+ processes: [
63
+ {command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
64
+ {command: "run worker", id: "worker", outputLines: 5, policy: "companion"}
65
+ ],
66
+ proxy: {host: "127.0.0.1", port: 8182}
67
+ })
68
+
69
+ assert.deepEqual(issues, [])
70
+ assert.equal(config.processes[0].outputLines, 50)
71
+ assert.equal(config.processes[1].outputLines, 5)
72
+ })
73
+
74
+ test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
75
+ const {issues} = validateConfig({
76
+ application: "demo",
77
+ control: {path: "/tmp/demo.sock"},
78
+ processes: [
79
+ {command: "run web", id: "web", outputLines: 0, policy: "proxied", port: {from: 18000, to: 18099}}
80
+ ],
81
+ proxy: {host: "127.0.0.1", port: 8182}
82
+ })
83
+
84
+ const issue = issues.find((candidate) => candidate.message === "processes[0].outputLines must be a positive integer")
85
+
86
+ assert.ok(issue, `expected an outputLines issue in ${JSON.stringify(issues.map((candidate) => candidate.message))}`)
87
+ assert.match(issue.fix, /positive integer/)
88
+ })
89
+
90
+ test("validateConfig parses control.mode, defaults it to unset, and rejects invalid modes", () => {
91
+ /**
92
+ * @param {import("../src/json.js").JsonValue} control - Control config under test.
93
+ * @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
94
+ */
95
+ const validateControl = (control) => validateConfig({
96
+ application: "demo",
97
+ control,
98
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
99
+ proxy: {host: "127.0.0.1", port: 8182}
100
+ })
101
+
102
+ const parsed = validateControl({mode: "660", path: "/tmp/demo.sock"})
103
+
104
+ assert.deepEqual(parsed.issues, [])
105
+ assert.equal(parsed.config.control.mode, 0o660)
106
+
107
+ // Minimal octal strings are accepted, matching the numeric boundary (e.g. 0).
108
+ const minimal = validateControl({mode: "0", path: "/tmp/demo.sock"})
109
+
110
+ assert.deepEqual(minimal.issues, [])
111
+ assert.equal(minimal.config.control.mode, 0)
112
+
113
+ assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.mode, undefined)
114
+
115
+ const invalid = validateControl({mode: "abc", path: "/tmp/demo.sock"})
116
+
117
+ assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
118
+ })
119
+
58
120
  test("normalizeConfig throws an aggregated error listing every issue", () => {
59
121
  assert.throws(
60
122
  () => normalizeConfig({
@@ -0,0 +1,94 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import net from "node:net"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import test, {after, before} from "node:test"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+ import {sendControlCommand} from "../src/control-client.js"
12
+
13
+ let root = ""
14
+ let socketPath = ""
15
+ let daemon = /** @type {RollbridgeDaemon | undefined} */ (undefined)
16
+
17
+ before(async () => {
18
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-control-"))
19
+ socketPath = path.join(root, "rollbridge.sock")
20
+
21
+ const config = normalizeConfig({
22
+ application: "rollbridge-control-test",
23
+ control: {path: socketPath},
24
+ processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
25
+ proxy: {host: "127.0.0.1", port: 0}
26
+ })
27
+
28
+ daemon = new RollbridgeDaemon({config, logger: () => {}})
29
+ await daemon.start()
30
+ })
31
+
32
+ after(async () => {
33
+ if (daemon) await daemon.shutdown()
34
+ await fs.rm(root, {force: true, recursive: true})
35
+ })
36
+
37
+ /**
38
+ * Writes a single raw line to the control socket and returns the parsed response.
39
+ * @param {string} rawLine - Exact line to send (no trailing newline).
40
+ * @returns {Promise<Record<string, import("../src/json.js").JsonValue>>} Parsed response.
41
+ */
42
+ async function sendRawControlLine(rawLine) {
43
+ return await new Promise((resolve, reject) => {
44
+ const socket = net.createConnection(socketPath)
45
+ let buffer = ""
46
+
47
+ socket.setEncoding("utf8")
48
+ socket.once("error", reject)
49
+ socket.on("data", (chunk) => {
50
+ buffer += chunk
51
+
52
+ const newlineIndex = buffer.indexOf("\n")
53
+
54
+ if (newlineIndex < 0) return
55
+
56
+ socket.end()
57
+ resolve(JSON.parse(buffer.slice(0, newlineIndex)))
58
+ })
59
+ socket.once("connect", () => socket.write(`${rawLine}\n`))
60
+ })
61
+ }
62
+
63
+ test("malformed JSON returns an error response without crashing the daemon", async () => {
64
+ const response = await sendRawControlLine("this is not json")
65
+
66
+ assert.equal(response.status, "error")
67
+ assert.match(String(response.error), /JSON/)
68
+
69
+ // The daemon stays up and still answers valid commands afterwards.
70
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
71
+
72
+ assert.equal(status.application, "rollbridge-control-test")
73
+ })
74
+
75
+ test("non-object JSON is rejected as an invalid control command", async () => {
76
+ const response = await sendRawControlLine("123")
77
+
78
+ assert.equal(response.status, "error")
79
+ assert.equal(response.error, "Control command must be an object")
80
+ })
81
+
82
+ test("an unknown control command returns a clear error", async () => {
83
+ const response = await sendRawControlLine(JSON.stringify({command: "bogus"}))
84
+
85
+ assert.equal(response.status, "error")
86
+ assert.equal(response.error, "Unknown command: bogus")
87
+ })
88
+
89
+ test("a known command missing a required field returns a field error", async () => {
90
+ const response = await sendRawControlLine(JSON.stringify({command: "deploy"}))
91
+
92
+ assert.equal(response.status, "error")
93
+ assert.equal(response.error, "releasePath is required")
94
+ })
@@ -0,0 +1,46 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import test from "node:test"
5
+ import ManagedProcess from "../src/managed-process.js"
6
+
7
+ /**
8
+ * Builds a managed process that is never spawned, for exercising output retention directly.
9
+ * @param {number} outputLines - Recent output lines to retain and report.
10
+ * @returns {ManagedProcess} Managed process.
11
+ */
12
+ function buildProcess(outputLines) {
13
+ return new ManagedProcess({
14
+ command: "true",
15
+ cwd: undefined,
16
+ env: {},
17
+ id: "web",
18
+ logger: () => {},
19
+ outputLines,
20
+ restartDelayMs: 1000,
21
+ shouldRestart: () => false,
22
+ stopTimeoutMs: 1000
23
+ })
24
+ }
25
+
26
+ test("retains and reports only the configured number of recent output lines", () => {
27
+ const managed = buildProcess(3)
28
+
29
+ managed.appendLog("stdout", "a\nb\nc\nd\ne\n")
30
+
31
+ const {logs} = managed.status()
32
+
33
+ assert.equal(logs.length, 3)
34
+ assert.deepEqual(logs.map((entry) => entry.line), ["c", "d", "e"])
35
+ assert.equal(logs[0].stream, "stdout")
36
+ })
37
+
38
+ test("keeps every output line when fewer than the retention limit are produced", () => {
39
+ const managed = buildProcess(50)
40
+
41
+ managed.appendLog("stderr", "one\ntwo\n")
42
+
43
+ const {logs} = managed.status()
44
+
45
+ assert.deepEqual(logs.map((entry) => entry.line), ["one", "two"])
46
+ })
@@ -0,0 +1,128 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import fs from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import test from "node:test"
8
+ import {fileURLToPath} from "node:url"
9
+ import RollbridgeDaemon from "../src/daemon.js"
10
+ import {normalizeConfig} from "../src/config.js"
11
+
12
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
13
+ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
14
+
15
+ /**
16
+ * @param {string} root - Fixture root used for the control socket.
17
+ * @param {number} restartDelayMs - Delay before a crashed process is restarted.
18
+ * @returns {import("../src/config.js").RollbridgeConfig} Normalized config with one proxied web process.
19
+ */
20
+ function buildConfig(root, restartDelayMs) {
21
+ return normalizeConfig({
22
+ application: "rollbridge-proxy-test",
23
+ control: {path: path.join(root, "rollbridge.sock")},
24
+ processes: [
25
+ {
26
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
27
+ health: {intervalMs: 50, path: "/ping", timeoutMs: 3000},
28
+ id: "web",
29
+ policy: "proxied",
30
+ port: {from: 0, to: 0},
31
+ restartDelayMs
32
+ }
33
+ ],
34
+ proxy: {drainTimeoutMs: 1000, forceStopTimeoutMs: 500, healthPath: "/ping", healthTimeoutMs: 3000, host: "127.0.0.1", port: 0}
35
+ })
36
+ }
37
+
38
+ /**
39
+ * @param {RollbridgeDaemon} daemon - Daemon.
40
+ * @param {string} pathName - Request path.
41
+ * @returns {Promise<Response>} Proxy response.
42
+ */
43
+ async function proxyFetch(daemon, pathName) {
44
+ return await fetch(`http://127.0.0.1:${daemon.getProxyPort()}${pathName}`)
45
+ }
46
+
47
+ /**
48
+ * @param {RollbridgeDaemon} daemon - Daemon.
49
+ * @param {string} releaseId - Active release id.
50
+ * @returns {number} The web process pid reported by status.
51
+ */
52
+ function webPid(daemon, releaseId) {
53
+ const release = daemon.status().releases.find((candidate) => candidate.releaseId === releaseId)
54
+
55
+ assert.ok(release, `Release ${releaseId} should be present`)
56
+
57
+ const web = release.processes.find((candidate) => candidate.id === "web")
58
+
59
+ assert.ok(web && typeof web.pid === "number", "web process should report a pid")
60
+
61
+ return web.pid
62
+ }
63
+
64
+ /**
65
+ * @param {() => Promise<boolean> | boolean} callback - Probe.
66
+ * @returns {Promise<void>} Resolves once the probe returns true.
67
+ */
68
+ async function waitFor(callback) {
69
+ const deadline = Date.now() + 3000
70
+
71
+ while (Date.now() < deadline) {
72
+ if (await callback()) return
73
+ await new Promise((resolve) => setTimeout(resolve, 25))
74
+ }
75
+
76
+ throw new Error("Timed out waiting for condition")
77
+ }
78
+
79
+ test("proxy returns 502 while the active release web process is down", async () => {
80
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-proxy-"))
81
+ const daemon = new RollbridgeDaemon({config: buildConfig(root, 60000), logger: () => {}})
82
+
83
+ await daemon.start()
84
+
85
+ try {
86
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
87
+ assert.equal((await proxyFetch(daemon, "/release")).status, 200)
88
+
89
+ // Kill the web process group so the active release exits unexpectedly; restart is held off for 60s.
90
+ process.kill(-webPid(daemon, "v1"), "SIGKILL")
91
+
92
+ let lastStatus = 0
93
+
94
+ await waitFor(async () => {
95
+ lastStatus = (await proxyFetch(daemon, "/release")).status
96
+
97
+ return lastStatus === 502
98
+ })
99
+
100
+ assert.equal(lastStatus, 502)
101
+ } finally {
102
+ await daemon.shutdown()
103
+ await fs.rm(root, {force: true, recursive: true})
104
+ }
105
+ })
106
+
107
+ test("proxy recovers once the crashed web process restarts", async () => {
108
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-proxy-"))
109
+ const daemon = new RollbridgeDaemon({config: buildConfig(root, 300), logger: () => {}})
110
+
111
+ await daemon.start()
112
+
113
+ try {
114
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
115
+ assert.equal((await proxyFetch(daemon, "/release")).status, 200)
116
+
117
+ process.kill(-webPid(daemon, "v1"), "SIGKILL")
118
+
119
+ // First confirm the crash actually takes the proxy down (502), so passing requires
120
+ // observing the outage, then confirm the restart brings the proxy back to 200.
121
+ await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 502)
122
+ await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 200)
123
+ assert.equal((await proxyFetch(daemon, "/release")).status, 200)
124
+ } finally {
125
+ await daemon.shutdown()
126
+ await fs.rm(root, {force: true, recursive: true})
127
+ }
128
+ })
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import test from "node:test"
5
+ import ReleaseGroup from "../src/release-group.js"
6
+ import {normalizeConfig} from "../src/config.js"
7
+
8
+ /**
9
+ * @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
10
+ * @returns {ReleaseGroup} A release group ready for buildProcess.
11
+ */
12
+ function buildRelease(webProcess) {
13
+ const config = normalizeConfig({
14
+ application: "demo",
15
+ control: {path: "/tmp/rollbridge-release-group.sock"},
16
+ processes: [webProcess],
17
+ proxy: {host: "127.0.0.1", port: 0}
18
+ })
19
+
20
+ return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1"})
21
+ }
22
+
23
+ test("templates interpolate values from the daemon environment", () => {
24
+ const release = buildRelease({
25
+ command: "run --token {{env.ROLLBRIDGE_ENV_TEST}}",
26
+ env: {DOWNSTREAM_TOKEN: "{{env.ROLLBRIDGE_ENV_TEST}}"},
27
+ id: "web",
28
+ policy: "proxied",
29
+ port: {from: 0, to: 0}
30
+ })
31
+
32
+ process.env.ROLLBRIDGE_ENV_TEST = "from-daemon"
33
+
34
+ try {
35
+ const managed = release.buildProcess(release.config.processes[0])
36
+
37
+ assert.equal(managed.command, "run --token from-daemon")
38
+ assert.equal(managed.env.DOWNSTREAM_TOKEN, "from-daemon")
39
+ } finally {
40
+ delete process.env.ROLLBRIDGE_ENV_TEST
41
+ }
42
+ })
43
+
44
+ test("a referenced daemon environment variable that is unset fails fast", () => {
45
+ const release = buildRelease({
46
+ command: "run {{env.ROLLBRIDGE_ENV_MISSING}}",
47
+ id: "web",
48
+ policy: "proxied",
49
+ port: {from: 0, to: 0}
50
+ })
51
+
52
+ delete process.env.ROLLBRIDGE_ENV_MISSING
53
+
54
+ assert.throws(
55
+ () => release.buildProcess(release.config.processes[0]),
56
+ /Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
57
+ )
58
+ })
@@ -213,6 +213,29 @@ test("a control socket held by a non-Rollbridge process reports a generic confli
213
213
  }
214
214
  })
215
215
 
216
+ test("applies the configured control socket permission mode", async () => {
217
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
218
+ const socketPath = path.join(root, "rollbridge.sock")
219
+ const config = normalizeConfig({
220
+ application: "rollbridge-test",
221
+ control: {mode: "660", path: socketPath},
222
+ processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
223
+ proxy: {host: "127.0.0.1", port: 0}
224
+ })
225
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
226
+
227
+ await daemon.start()
228
+
229
+ try {
230
+ const stats = await fs.stat(socketPath)
231
+
232
+ assert.equal(stats.mode & 0o777, 0o660)
233
+ } finally {
234
+ await daemon.shutdown()
235
+ await fs.rm(root, {force: true, recursive: true})
236
+ }
237
+ })
238
+
216
239
  test("deploy can ensure the daemon before sending the release command", async () => {
217
240
  const fixture = await createFixture()
218
241
  const configPath = await writeConfigFile(fixture.config, fixture.root)