rollbridge 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -4
- package/TODO.md +42 -40
- package/docs/cli.md +146 -6
- package/docs/config.md +139 -2
- package/docs/logging.md +77 -0
- package/docs/releasing.md +53 -0
- package/docs/tensorbuzz-runbook.md +129 -0
- package/docs/velocious.md +49 -11
- package/docs/workers.md +115 -0
- package/package.json +1 -1
- package/src/cli.js +290 -1
- package/src/config.js +169 -6
- package/src/daemon.js +216 -13
- package/src/doctor.js +177 -0
- package/src/event-log.js +47 -0
- package/src/managed-process.js +225 -16
- package/src/process-memory.js +110 -0
- package/src/recover.js +134 -0
- package/src/release-group.js +71 -21
- package/src/state-store.js +103 -0
- package/src/system-ids.js +71 -0
- package/src/template.js +32 -0
- package/test/completion.test.js +64 -0
- package/test/config-validation.test.js +227 -0
- package/test/doctor.test.js +205 -3
- package/test/event-log.test.js +46 -0
- package/test/fixtures/memory-hog.js +19 -0
- package/test/managed-process.test.js +290 -0
- package/test/process-memory.test.js +40 -0
- package/test/recover.test.js +162 -0
- package/test/release-group.test.js +22 -0
- package/test/rollbridge.test.js +523 -6
- package/test/state-store.test.js +69 -0
- package/test/system-ids.test.js +24 -0
|
@@ -126,6 +126,175 @@ test("validateConfig defaults the restart policy, accepts overrides, and rejects
|
|
|
126
126
|
assert.ok(validateRestart({maxRestarts: 1.5}).issues.some((issue) => issue.message === "processes[0].restart.maxRestarts must be a non-negative integer"))
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values", () => {
|
|
130
|
+
/**
|
|
131
|
+
* @param {import("../src/json.js").JsonValue} lifecycle - Lifecycle config under test, or undefined.
|
|
132
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
133
|
+
*/
|
|
134
|
+
const validateLifecycle = (lifecycle) => validateConfig({
|
|
135
|
+
application: "demo",
|
|
136
|
+
control: {path: "/tmp/demo.sock"},
|
|
137
|
+
processes: [{command: "run web", id: "web", lifecycle, policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
138
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Omitted → no commands, zero drain.
|
|
142
|
+
assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {drainTimeoutMs: 0})
|
|
143
|
+
|
|
144
|
+
const custom = validateLifecycle({drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(custom.issues, [])
|
|
147
|
+
assert.equal(custom.config.processes[0].lifecycle.quietCommand, "kill -TSTP $ROLLBRIDGE_PID")
|
|
148
|
+
assert.equal(custom.config.processes[0].lifecycle.stopCommand, "kill -TERM $ROLLBRIDGE_PID")
|
|
149
|
+
assert.equal(custom.config.processes[0].lifecycle.drainTimeoutMs, 30000)
|
|
150
|
+
|
|
151
|
+
const invalid = validateLifecycle({drainTimeoutMs: -1, quietCommand: 5})
|
|
152
|
+
const messages = invalid.issues.map((issue) => issue.message)
|
|
153
|
+
|
|
154
|
+
assert.ok(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number"), JSON.stringify(messages))
|
|
155
|
+
assert.ok(messages.includes("processes[0].lifecycle.quietCommand must be a string"), JSON.stringify(messages))
|
|
156
|
+
|
|
157
|
+
// drainCommand needs a positive drainTimeoutMs to bound it; otherwise the drain step is skipped.
|
|
158
|
+
assert.ok(validateLifecycle({drainCommand: "drain"}).issues
|
|
159
|
+
.some((issue) => issue.message === "processes[0].lifecycle.drainCommand requires a positive processes[0].lifecycle.drainTimeoutMs"))
|
|
160
|
+
assert.deepEqual(validateLifecycle({drainCommand: "drain", drainTimeoutMs: 1000}).issues, [])
|
|
161
|
+
|
|
162
|
+
// A stopCommand replaces stopSignal, so a stopCommand alongside the default SIGTERM is fine.
|
|
163
|
+
assert.deepEqual(validateLifecycle({stopCommand: "kill -TERM $ROLLBRIDGE_PID"}).issues, [])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("validateConfig rejects a custom stopSignal alongside a stopCommand that would ignore it", () => {
|
|
167
|
+
/**
|
|
168
|
+
* @param {Record<string, import("../src/json.js").JsonValue>} overrides - Extra fields merged onto the worker process.
|
|
169
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
170
|
+
*/
|
|
171
|
+
const validateWorker = (overrides) => validateConfig({
|
|
172
|
+
application: "demo",
|
|
173
|
+
control: {path: "/tmp/demo.sock"},
|
|
174
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}, {command: "run worker", id: "worker", policy: "companion", ...overrides}],
|
|
175
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// A custom stopSignal with a stopCommand is contradictory: stopCommand runs instead of the signal.
|
|
179
|
+
assert.ok(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGINT"}).issues
|
|
180
|
+
.some((issue) => /sets both lifecycle.stopCommand and a custom stopSignal/.test(issue.message)),
|
|
181
|
+
"a custom stopSignal next to a stopCommand must be rejected")
|
|
182
|
+
|
|
183
|
+
// stopSignal alone (no stopCommand) is fine — the signal is what stops the worker.
|
|
184
|
+
assert.deepEqual(validateWorker({stopSignal: "SIGINT"}).issues, [])
|
|
185
|
+
|
|
186
|
+
// stopCommand alone (default SIGTERM) is fine — nothing custom is silently dropped.
|
|
187
|
+
assert.deepEqual(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}}).issues, [])
|
|
188
|
+
|
|
189
|
+
// An explicit default stopSignal next to a stopCommand is not flagged (SIGTERM is the default).
|
|
190
|
+
assert.deepEqual(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGTERM"}).issues, [])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("validateConfig defaults replicas, accepts companion replicas, and rejects bad placements", () => {
|
|
194
|
+
/**
|
|
195
|
+
* @param {import("../src/json.js").JsonValue} worker - Second (worker) process definition.
|
|
196
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
197
|
+
*/
|
|
198
|
+
const validateWorker = (worker) => validateConfig({
|
|
199
|
+
application: "demo",
|
|
200
|
+
control: {path: "/tmp/demo.sock"},
|
|
201
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}, worker],
|
|
202
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
assert.equal(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].replicas, 1)
|
|
206
|
+
|
|
207
|
+
const replicated = validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 4})
|
|
208
|
+
|
|
209
|
+
assert.deepEqual(replicated.issues, [])
|
|
210
|
+
assert.equal(replicated.config.processes[1].replicas, 4)
|
|
211
|
+
|
|
212
|
+
// replicas > 1 on a companion with a port is rejected.
|
|
213
|
+
assert.ok(validateWorker({command: "run worker", id: "worker", policy: "companion", port: {from: 19000, to: 19099}, replicas: 2}).issues
|
|
214
|
+
.some((issue) => /can only set replicas > 1 on a companion process without a port/.test(issue.message)))
|
|
215
|
+
|
|
216
|
+
// replicas > 1 on a non-companion policy is rejected.
|
|
217
|
+
assert.ok(validateWorker({command: "run broker", id: "broker", policy: "service", replicas: 2}).issues
|
|
218
|
+
.some((issue) => /can only set replicas > 1 on a companion/.test(issue.message)))
|
|
219
|
+
|
|
220
|
+
// Non-positive replicas is rejected.
|
|
221
|
+
assert.ok(validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 0}).issues
|
|
222
|
+
.some((issue) => issue.message === "processes[1].replicas must be a positive integer"))
|
|
223
|
+
|
|
224
|
+
// A "#" in a process id (reserved for replica instance ids) is rejected.
|
|
225
|
+
assert.ok(validateWorker({command: "run worker", id: "work#er", policy: "companion"}).issues
|
|
226
|
+
.some((issue) => /must not contain "#"/.test(issue.message)))
|
|
227
|
+
|
|
228
|
+
// nonBlockingDrain defaults to false, is accepted on a companion, and rejected elsewhere.
|
|
229
|
+
assert.equal(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].nonBlockingDrain, false)
|
|
230
|
+
|
|
231
|
+
const draining = validateWorker({command: "run worker", id: "worker", nonBlockingDrain: true, policy: "companion"})
|
|
232
|
+
|
|
233
|
+
assert.deepEqual(draining.issues, [])
|
|
234
|
+
assert.equal(draining.config.processes[1].nonBlockingDrain, true)
|
|
235
|
+
|
|
236
|
+
assert.ok(validateWorker({command: "run b", id: "broker", nonBlockingDrain: true, policy: "service"}).issues
|
|
237
|
+
.some((issue) => /can only set nonBlockingDrain on a companion/.test(issue.message)))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("validateConfig defaults stopSignal, accepts valid signals, and rejects unknown ones", () => {
|
|
241
|
+
/**
|
|
242
|
+
* @param {import("../src/json.js").JsonValue} stopSignal - Stop signal under test, or undefined to omit it.
|
|
243
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
244
|
+
*/
|
|
245
|
+
const validateStopSignal = (stopSignal) => validateConfig({
|
|
246
|
+
application: "demo",
|
|
247
|
+
control: {path: "/tmp/demo.sock"},
|
|
248
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}, stopSignal}],
|
|
249
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
assert.equal(validateStopSignal(undefined).config.processes[0].stopSignal, "SIGTERM")
|
|
253
|
+
|
|
254
|
+
const custom = validateStopSignal("SIGINT")
|
|
255
|
+
|
|
256
|
+
assert.deepEqual(custom.issues, [])
|
|
257
|
+
assert.equal(custom.config.processes[0].stopSignal, "SIGINT")
|
|
258
|
+
|
|
259
|
+
const invalid = validateStopSignal("SIGBOGUS")
|
|
260
|
+
|
|
261
|
+
assert.ok(invalid.issues.some((issue) => issue.message === "processes[0].stopSignal must be a valid signal name"), JSON.stringify(invalid.issues.map((issue) => issue.message)))
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test("validateConfig normalizes memory supervision and rejects bad values", () => {
|
|
265
|
+
/**
|
|
266
|
+
* @param {import("../src/json.js").JsonValue} memory - Memory config under test, or undefined to omit it.
|
|
267
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
268
|
+
*/
|
|
269
|
+
const validateMemory = (memory) => validateConfig({
|
|
270
|
+
application: "demo",
|
|
271
|
+
control: {path: "/tmp/demo.sock"},
|
|
272
|
+
processes: [{command: "run web", id: "web", memory, policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
273
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// Omitted → monitoring off.
|
|
277
|
+
assert.equal(validateMemory(undefined).config.processes[0].memory, undefined)
|
|
278
|
+
|
|
279
|
+
const custom = validateMemory({checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
|
|
280
|
+
|
|
281
|
+
assert.deepEqual(custom.issues, [])
|
|
282
|
+
assert.deepEqual(custom.config.processes[0].memory, {checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
|
|
283
|
+
|
|
284
|
+
// Defaults checkIntervalMs and warnBytes when only limitBytes is given.
|
|
285
|
+
const defaulted = validateMemory({limitBytes: 1048576})
|
|
286
|
+
|
|
287
|
+
assert.deepEqual(defaulted.issues, [])
|
|
288
|
+
assert.deepEqual(defaulted.config.processes[0].memory, {checkIntervalMs: 5000, limitBytes: 1048576, warnBytes: 0})
|
|
289
|
+
|
|
290
|
+
const invalid = validateMemory({checkIntervalMs: 0, limitBytes: 0, warnBytes: -1})
|
|
291
|
+
const messages = invalid.issues.map((issue) => issue.message)
|
|
292
|
+
|
|
293
|
+
assert.ok(messages.includes("processes[0].memory.limitBytes must be a positive integer"), JSON.stringify(messages))
|
|
294
|
+
assert.ok(messages.includes("processes[0].memory.warnBytes must be a non-negative integer"), JSON.stringify(messages))
|
|
295
|
+
assert.ok(messages.includes("processes[0].memory.checkIntervalMs must be a positive number"), JSON.stringify(messages))
|
|
296
|
+
})
|
|
297
|
+
|
|
129
298
|
test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
|
|
130
299
|
const {issues} = validateConfig({
|
|
131
300
|
application: "demo",
|
|
@@ -172,6 +341,41 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
|
|
|
172
341
|
assert.ok(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777"))
|
|
173
342
|
})
|
|
174
343
|
|
|
344
|
+
test("validateConfig accepts control owner/group as ids or names and rejects bad values", () => {
|
|
345
|
+
/**
|
|
346
|
+
* @param {import("../src/json.js").JsonValue} control - Control config under test.
|
|
347
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
348
|
+
*/
|
|
349
|
+
const validateControl = (control) => validateConfig({
|
|
350
|
+
application: "demo",
|
|
351
|
+
control,
|
|
352
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
353
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const numeric = validateControl({group: 1000, owner: 1000, path: "/tmp/demo.sock"})
|
|
357
|
+
|
|
358
|
+
assert.deepEqual(numeric.issues, [])
|
|
359
|
+
assert.equal(numeric.config.control.owner, 1000)
|
|
360
|
+
assert.equal(numeric.config.control.group, 1000)
|
|
361
|
+
|
|
362
|
+
const named = validateControl({group: "deploy", owner: "deploy", path: "/tmp/demo.sock"})
|
|
363
|
+
|
|
364
|
+
assert.deepEqual(named.issues, [])
|
|
365
|
+
assert.equal(named.config.control.owner, "deploy")
|
|
366
|
+
assert.equal(named.config.control.group, "deploy")
|
|
367
|
+
|
|
368
|
+
// Unset by default.
|
|
369
|
+
assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.owner, undefined)
|
|
370
|
+
assert.equal(validateControl({path: "/tmp/demo.sock"}).config.control.group, undefined)
|
|
371
|
+
|
|
372
|
+
const invalid = validateControl({group: -1, owner: true, path: "/tmp/demo.sock"})
|
|
373
|
+
const messages = invalid.issues.map((issue) => issue.message)
|
|
374
|
+
|
|
375
|
+
assert.ok(messages.includes("control.owner must be a non-negative integer id or a name"), JSON.stringify(messages))
|
|
376
|
+
assert.ok(messages.includes("control.group must be a non-negative integer id or a name"), JSON.stringify(messages))
|
|
377
|
+
})
|
|
378
|
+
|
|
175
379
|
test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
|
|
176
380
|
/**
|
|
177
381
|
* @param {import("../src/json.js").JsonValue} health - Health config under test, or undefined to omit it.
|
|
@@ -228,6 +432,29 @@ test("validateConfig defaults releaseRetention, accepts overrides, and rejects b
|
|
|
228
432
|
assert.ok(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number"))
|
|
229
433
|
})
|
|
230
434
|
|
|
435
|
+
test("validateConfig leaves statePath unset by default, accepts a string, and rejects non-strings", () => {
|
|
436
|
+
/**
|
|
437
|
+
* @param {import("../src/json.js").JsonValue} statePath - statePath under test, or undefined.
|
|
438
|
+
* @returns {{config: import("../src/config.js").RollbridgeConfig, issues: import("../src/config.js").ConfigIssue[]}} Validation result.
|
|
439
|
+
*/
|
|
440
|
+
const validateStatePath = (statePath) => validateConfig({
|
|
441
|
+
application: "demo",
|
|
442
|
+
control: {path: "/tmp/demo.sock"},
|
|
443
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
444
|
+
proxy: {host: "127.0.0.1", port: 8182},
|
|
445
|
+
statePath
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
assert.equal(validateStatePath(undefined).config.statePath, undefined)
|
|
449
|
+
|
|
450
|
+
const set = validateStatePath("/var/lib/rollbridge/demo.state.json")
|
|
451
|
+
|
|
452
|
+
assert.deepEqual(set.issues, [])
|
|
453
|
+
assert.equal(set.config.statePath, "/var/lib/rollbridge/demo.state.json")
|
|
454
|
+
|
|
455
|
+
assert.ok(validateStatePath(123).issues.some((issue) => issue.message === "statePath must be a string"))
|
|
456
|
+
})
|
|
457
|
+
|
|
231
458
|
test("normalizeConfig throws an aggregated error listing every issue", () => {
|
|
232
459
|
assert.throws(
|
|
233
460
|
() => normalizeConfig({
|
package/test/doctor.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import assert from "node:assert/strict"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
import {once} from "node:events"
|
|
4
6
|
import fs from "node:fs/promises"
|
|
5
7
|
import net from "node:net"
|
|
6
8
|
import os from "node:os"
|
|
@@ -8,21 +10,24 @@ import path from "node:path"
|
|
|
8
10
|
import test from "node:test"
|
|
9
11
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
10
12
|
import {normalizeConfig} from "../src/config.js"
|
|
11
|
-
import {runEnvironmentChecks} from "../src/doctor.js"
|
|
13
|
+
import {runEnvironmentChecks, runReleaseChecks} from "../src/doctor.js"
|
|
14
|
+
import {writeState} from "../src/state-store.js"
|
|
12
15
|
import {runCli} from "../src/cli.js"
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* @param {object} args - Options.
|
|
16
19
|
* @param {string} args.controlPath - Control socket path.
|
|
17
20
|
* @param {number} args.proxyPort - Proxy port.
|
|
21
|
+
* @param {string} [args.statePath] - State file path.
|
|
18
22
|
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
19
23
|
*/
|
|
20
|
-
function buildConfig({controlPath, proxyPort}) {
|
|
24
|
+
function buildConfig({controlPath, proxyPort, statePath}) {
|
|
21
25
|
return normalizeConfig({
|
|
22
26
|
application: "doctor-test",
|
|
23
27
|
control: {path: controlPath},
|
|
24
28
|
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
25
|
-
proxy: {host: "127.0.0.1", port: proxyPort}
|
|
29
|
+
proxy: {host: "127.0.0.1", port: proxyPort},
|
|
30
|
+
...(statePath ? {statePath} : {})
|
|
26
31
|
})
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -102,6 +107,79 @@ test("runEnvironmentChecks reports an unavailable proxy port", async () => {
|
|
|
102
107
|
}
|
|
103
108
|
})
|
|
104
109
|
|
|
110
|
+
test("runEnvironmentChecks checks the state path directory and reports no orphans when none exist", async () => {
|
|
111
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort(), statePath: path.join(root, "state.json")}))
|
|
115
|
+
|
|
116
|
+
assert.equal(checkNamed(checks, "state path directory").ok, true)
|
|
117
|
+
assert.equal(checkNamed(checks, "orphaned processes").ok, true)
|
|
118
|
+
} finally {
|
|
119
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("runEnvironmentChecks omits state checks when no statePath is configured", async () => {
|
|
124
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()}))
|
|
128
|
+
|
|
129
|
+
assert.ok(!checks.some((check) => check.name === "state path directory"))
|
|
130
|
+
assert.ok(!checks.some((check) => check.name === "orphaned processes"))
|
|
131
|
+
} finally {
|
|
132
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("runEnvironmentChecks does not flag orphans while a daemon is running", async () => {
|
|
137
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
138
|
+
const statePath = path.join(root, "state.json")
|
|
139
|
+
const config = buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort(), statePath})
|
|
140
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
141
|
+
|
|
142
|
+
await daemon.start()
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const checks = await runEnvironmentChecks(config)
|
|
146
|
+
const orphanCheck = checkNamed(checks, "orphaned processes")
|
|
147
|
+
|
|
148
|
+
// A running daemon's persisted pids are its own managed processes, not orphans.
|
|
149
|
+
assert.equal(orphanCheck.ok, true)
|
|
150
|
+
assert.match(orphanCheck.detail, /a daemon is running/)
|
|
151
|
+
} finally {
|
|
152
|
+
await daemon.shutdown()
|
|
153
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("runEnvironmentChecks reports orphaned processes left in the state file", async () => {
|
|
158
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
159
|
+
const statePath = path.join(root, "state.json")
|
|
160
|
+
const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
|
|
161
|
+
|
|
162
|
+
await once(leftover, "spawn")
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await writeState(statePath, {
|
|
166
|
+
activeReleaseId: "v1",
|
|
167
|
+
releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "v1"}],
|
|
168
|
+
services: [],
|
|
169
|
+
singletons: []
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const checks = await runEnvironmentChecks(buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort(), statePath}))
|
|
173
|
+
const orphanCheck = checkNamed(checks, "orphaned processes")
|
|
174
|
+
|
|
175
|
+
assert.equal(orphanCheck.ok, false)
|
|
176
|
+
assert.match(orphanCheck.detail, new RegExp(`worker \\(pid ${leftover.pid}\\)`))
|
|
177
|
+
} finally {
|
|
178
|
+
leftover.kill("SIGKILL")
|
|
179
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
105
183
|
test("runEnvironmentChecks reports a missing control socket directory", async () => {
|
|
106
184
|
const checks = await runEnvironmentChecks(buildConfig({controlPath: "/rollbridge-doctor-missing-dir/rollbridge.sock", proxyPort: await freePort()}))
|
|
107
185
|
const directoryCheck = checkNamed(checks, "control socket directory")
|
|
@@ -131,6 +209,91 @@ test("runEnvironmentChecks fails when a Rollbridge daemon already holds the sock
|
|
|
131
209
|
}
|
|
132
210
|
})
|
|
133
211
|
|
|
212
|
+
/**
|
|
213
|
+
* @param {object} args - Options.
|
|
214
|
+
* @param {string} args.root - Temp root directory (holds the control socket).
|
|
215
|
+
* @param {import("../src/json.js").JsonValue[]} args.processes - Process definitions to validate.
|
|
216
|
+
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
217
|
+
*/
|
|
218
|
+
function releaseConfig({processes, root}) {
|
|
219
|
+
return normalizeConfig({
|
|
220
|
+
application: "doctor-release-test",
|
|
221
|
+
control: {path: path.join(root, "rollbridge.sock")},
|
|
222
|
+
processes,
|
|
223
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test("runReleaseChecks passes for an existing release with resolvable templates", async () => {
|
|
228
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
229
|
+
const releasePath = path.join(root, "release")
|
|
230
|
+
|
|
231
|
+
await fs.mkdir(path.join(releasePath, "backend"), {recursive: true})
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const config = releaseConfig({processes: [{command: "run web --port {{port}} --release {{releaseId}}", cwd: "{{releasePath}}/backend", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
235
|
+
const checks = await runReleaseChecks(config, {releasePath})
|
|
236
|
+
|
|
237
|
+
assert.equal(checkNamed(checks, "release path").ok, true)
|
|
238
|
+
assert.equal(checkNamed(checks, "process templates").ok, true)
|
|
239
|
+
assert.equal(checkNamed(checks, "process working directories").ok, true)
|
|
240
|
+
} finally {
|
|
241
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test("runReleaseChecks flags a command that references an undefined template variable", async () => {
|
|
246
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
247
|
+
const releasePath = path.join(root, "release")
|
|
248
|
+
|
|
249
|
+
await fs.mkdir(releasePath, {recursive: true})
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const config = releaseConfig({processes: [{command: "run web --secret {{missingVar}}", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
253
|
+
const templates = checkNamed(await runReleaseChecks(config, {releasePath}), "process templates")
|
|
254
|
+
|
|
255
|
+
assert.equal(templates.ok, false)
|
|
256
|
+
assert.match(templates.detail, /web:.*missingVar/)
|
|
257
|
+
} finally {
|
|
258
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test("runReleaseChecks reports a missing process working directory", async () => {
|
|
263
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
264
|
+
const releasePath = path.join(root, "release")
|
|
265
|
+
|
|
266
|
+
// The release directory exists, but the process's rendered cwd subdirectory does not.
|
|
267
|
+
await fs.mkdir(releasePath, {recursive: true})
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const config = releaseConfig({processes: [{command: "run web", cwd: "{{releasePath}}/backend", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
271
|
+
const checks = await runReleaseChecks(config, {releasePath})
|
|
272
|
+
|
|
273
|
+
assert.equal(checkNamed(checks, "release path").ok, true)
|
|
274
|
+
|
|
275
|
+
const directories = checkNamed(checks, "process working directories")
|
|
276
|
+
|
|
277
|
+
assert.equal(directories.ok, false)
|
|
278
|
+
assert.match(directories.detail, /web .*backend/)
|
|
279
|
+
} finally {
|
|
280
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test("runReleaseChecks reports a missing release path", async () => {
|
|
285
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const config = releaseConfig({processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}], root})
|
|
289
|
+
const checks = await runReleaseChecks(config, {releasePath: path.join(root, "does-not-exist")})
|
|
290
|
+
|
|
291
|
+
assert.equal(checkNamed(checks, "release path").ok, false)
|
|
292
|
+
} finally {
|
|
293
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
134
297
|
/**
|
|
135
298
|
* Runs the CLI while capturing console output.
|
|
136
299
|
* @param {string[]} argv - Process argv.
|
|
@@ -226,3 +389,42 @@ test("doctor --json emits structured checks", async () => {
|
|
|
226
389
|
await fs.rm(badRoot, {force: true, recursive: true})
|
|
227
390
|
}
|
|
228
391
|
})
|
|
392
|
+
|
|
393
|
+
test("doctor --release-path adds release checks, passing or failing on the working directory", async () => {
|
|
394
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
|
|
395
|
+
const releasePath = path.join(root, "release")
|
|
396
|
+
|
|
397
|
+
await fs.mkdir(path.join(releasePath, "backend"), {recursive: true})
|
|
398
|
+
|
|
399
|
+
const rawConfig = {
|
|
400
|
+
application: "doctor-release-cli",
|
|
401
|
+
control: {path: path.join(root, "rollbridge.sock")},
|
|
402
|
+
processes: [{command: "run web --port {{port}}", cwd: "{{releasePath}}/backend", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
403
|
+
proxy: {host: "127.0.0.1", port: await freePort()}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await fs.writeFile(path.join(root, "rollbridge.js"), `module.exports = ${JSON.stringify(rawConfig)}\n`)
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const passing = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js"), "--release-path", releasePath])
|
|
410
|
+
|
|
411
|
+
assert.match(passing, /✓ release path:/)
|
|
412
|
+
assert.match(passing, /✓ process templates:/)
|
|
413
|
+
assert.match(passing, /✓ process working directories:/)
|
|
414
|
+
assert.match(passing, /All checks passed\./)
|
|
415
|
+
assert.notEqual(process.exitCode, 1)
|
|
416
|
+
|
|
417
|
+
// A release without the rendered backend directory fails the working-directories check.
|
|
418
|
+
const emptyRelease = path.join(root, "empty-release")
|
|
419
|
+
|
|
420
|
+
await fs.mkdir(emptyRelease, {recursive: true})
|
|
421
|
+
|
|
422
|
+
const failing = await captureCli(["node", "rollbridge", "doctor", "-c", path.join(root, "rollbridge.js"), "--release-path", emptyRelease])
|
|
423
|
+
|
|
424
|
+
assert.match(failing, /✗ process working directories:/)
|
|
425
|
+
assert.equal(process.exitCode, 1)
|
|
426
|
+
} finally {
|
|
427
|
+
process.exitCode = 0
|
|
428
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
429
|
+
}
|
|
430
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import test from "node:test"
|
|
5
|
+
import EventLog from "../src/event-log.js"
|
|
6
|
+
|
|
7
|
+
test("records events with a timestamp, message, and data", () => {
|
|
8
|
+
const log = new EventLog(10)
|
|
9
|
+
|
|
10
|
+
log.record("traffic switched", {releaseId: "v1"})
|
|
11
|
+
|
|
12
|
+
const [event] = log.recent()
|
|
13
|
+
|
|
14
|
+
assert.equal(event.message, "traffic switched")
|
|
15
|
+
assert.deepEqual(event.data, {releaseId: "v1"})
|
|
16
|
+
assert.match(event.at, /^\d{4}-\d{2}-\d{2}T.*Z$/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("drops the oldest events once the limit is exceeded", () => {
|
|
20
|
+
const log = new EventLog(3)
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < 5; index += 1) log.record("tick", {index})
|
|
23
|
+
|
|
24
|
+
const events = log.recent()
|
|
25
|
+
|
|
26
|
+
assert.equal(events.length, 3)
|
|
27
|
+
assert.deepEqual(events.map((event) => event.data.index), [2, 3, 4])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("recent(limit) returns only the most recent events, oldest first", () => {
|
|
31
|
+
const log = new EventLog(10)
|
|
32
|
+
|
|
33
|
+
for (let index = 0; index < 5; index += 1) log.record("tick", {index})
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(log.recent(2).map((event) => event.data.index), [3, 4])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("recent returns every event when the limit is omitted or not a positive number", () => {
|
|
39
|
+
const log = new EventLog(10)
|
|
40
|
+
|
|
41
|
+
for (let index = 0; index < 3; index += 1) log.record("tick", {index})
|
|
42
|
+
|
|
43
|
+
assert.equal(log.recent().length, 3)
|
|
44
|
+
assert.equal(log.recent(0).length, 3)
|
|
45
|
+
assert.equal(log.recent(99).length, 3)
|
|
46
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Allocates and fills memory above a configured size, then stays alive, to exercise
|
|
2
|
+
// memory supervision. ROLLBRIDGE_HOG_BYTES controls how much resident memory to hold.
|
|
3
|
+
|
|
4
|
+
const targetBytes = Number(process.env.ROLLBRIDGE_HOG_BYTES || 200 * 1024 * 1024)
|
|
5
|
+
const chunkBytes = 16 * 1024 * 1024
|
|
6
|
+
const buffers = []
|
|
7
|
+
let allocated = 0
|
|
8
|
+
|
|
9
|
+
while (allocated < targetBytes) {
|
|
10
|
+
const size = Math.min(chunkBytes, targetBytes - allocated)
|
|
11
|
+
|
|
12
|
+
// Fill so the pages are resident (counted in RSS), not lazily reserved.
|
|
13
|
+
buffers.push(Buffer.alloc(size, 1))
|
|
14
|
+
allocated += size
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Keep the buffers referenced and the process alive.
|
|
18
|
+
globalThis.__rollbridgeHog = buffers
|
|
19
|
+
setInterval(() => {}, 1000)
|