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 +56 -1
- package/TODO.md +7 -6
- package/examples/rollbridge.service +48 -0
- package/package.json +1 -1
- package/src/config.js +46 -3
- package/src/daemon.js +5 -0
- package/src/managed-process.js +8 -5
- package/src/release-group.js +2 -0
- package/test/config-validation.test.js +62 -0
- package/test/control-protocol.test.js +94 -0
- package/test/managed-process.test.js +46 -0
- package/test/proxy.test.js +128 -0
- package/test/release-group.test.js +58 -0
- package/test/rollbridge.test.js +23 -0
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
|
-
- [
|
|
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
|
-
- [
|
|
74
|
-
- [
|
|
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
|
-
- [
|
|
79
|
-
- [
|
|
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
|
-
- [
|
|
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
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
|
package/src/managed-process.js
CHANGED
|
@@ -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 >
|
|
120
|
-
this.logs.splice(0, this.logs.length -
|
|
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(-
|
|
230
|
+
logs: this.logs.slice(-this.outputLines),
|
|
228
231
|
pid: this.pid,
|
|
229
232
|
state: this.state
|
|
230
233
|
}
|
package/src/release-group.js
CHANGED
|
@@ -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
|
+
})
|
package/test/rollbridge.test.js
CHANGED
|
@@ -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)
|