rollbridge 0.1.49 → 0.1.55
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/AGENTS.md +5 -0
- package/README.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/docs/cli.md +7 -1
- package/docs/generation-deployment-contract.md +9 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/cli.js +10 -2
- package/src/daemon.js +102 -12
- package/src/process-guardian.js +5 -1
- package/src/release-group.js +48 -1
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +75 -14
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +160 -145
- package/test/health.test.js +6 -4
- package/test/logs.test.js +23 -17
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +254 -239
- package/test/owner-replacement.test.js +228 -223
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +16 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +388 -395
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {normalizeConfig, validateConfig} from "../src/config.js"
|
|
9
8
|
import {runCli} from "../src/cli.js"
|
|
10
9
|
|
|
10
|
+
describe("config-validation", () => {
|
|
11
|
+
|
|
11
12
|
test("validateConfig collects duplicate ids, proxied ports, and policy combinations", () => {
|
|
12
13
|
const {issues} = validateConfig({
|
|
13
14
|
application: "demo",
|
|
@@ -20,9 +21,9 @@ test("validateConfig collects duplicate ids, proxied ports, and policy combinati
|
|
|
20
21
|
})
|
|
21
22
|
const messages = issues.map((issue) => issue.message)
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
expect({value: Boolean(messages.includes("Duplicate process id: web")), context: `expected duplicate id issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
|
|
25
|
+
expect({value: Boolean(messages.includes("Proxied process web must define a port range")), context: `expected missing proxied port issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
|
|
26
|
+
expect(issues.every((issue) => typeof issue.fix === "string" && issue.fix.length > 0)).toBe(true)
|
|
26
27
|
})
|
|
27
28
|
|
|
28
29
|
test("validateConfig reports invalid ranges and missing proxied process without throwing", () => {
|
|
@@ -35,8 +36,8 @@ test("validateConfig reports invalid ranges and missing proxied process without
|
|
|
35
36
|
})
|
|
36
37
|
const messages = issues.map((issue) => issue.message)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
expect({value: Boolean(messages.includes("processes[0].port must be a positive port or valid {from, to} range")), context: `expected invalid range issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
|
|
40
|
+
expect({value: Boolean(messages.includes("Config must define exactly one proxied process; found 0")), context: `expected missing proxied process issue in ${JSON.stringify(messages)}`}).toMatchObject({value: true})
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
test("validateConfig returns a normalized config and no issues for a valid config", () => {
|
|
@@ -49,10 +50,10 @@ test("validateConfig returns a normalized config and no issues for a valid confi
|
|
|
49
50
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
50
51
|
})
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
expect(issues).toEqual([])
|
|
54
|
+
expect(config.processes.length).toBe(1)
|
|
55
|
+
expect(config.processes[0].policy).toBe("proxied")
|
|
56
|
+
expect(config.proxy.port).toBe(8182)
|
|
56
57
|
})
|
|
57
58
|
|
|
58
59
|
test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
|
|
@@ -65,9 +66,9 @@ test("validateConfig defaults wildcard proxy upstreams to loopback", () => {
|
|
|
65
66
|
proxy: {host: "0.0.0.0", port: 8182}
|
|
66
67
|
})
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
expect(issues).toEqual([])
|
|
70
|
+
expect(config.proxy.host).toBe("0.0.0.0")
|
|
71
|
+
expect(config.proxy.upstreamHost).toBe("127.0.0.1")
|
|
71
72
|
})
|
|
72
73
|
|
|
73
74
|
test("validateConfig accepts legacy takeover screens and process matchers", () => {
|
|
@@ -87,8 +88,8 @@ test("validateConfig accepts legacy takeover screens and process matchers", () =
|
|
|
87
88
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
88
89
|
})
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
expect(issues).toEqual([])
|
|
92
|
+
expect(config.legacyTakeover).toEqual({
|
|
92
93
|
forceStopTimeoutMs: 250,
|
|
93
94
|
processes: [
|
|
94
95
|
{includes: ["/srv/demo/", "velocious server", "--port 4500"], name: "legacy web"}
|
|
@@ -108,7 +109,7 @@ test("validateConfig rejects empty legacy takeover config", () => {
|
|
|
108
109
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
109
110
|
})
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
expect({value: Boolean(issues.some((issue) => issue.message === "legacyTakeover must define at least one screen or process matcher")), context: JSON.stringify(issues)}).toMatchObject({value: true})
|
|
112
113
|
})
|
|
113
114
|
|
|
114
115
|
test("validateConfig defaults outputLines and accepts a positive override", () => {
|
|
@@ -122,9 +123,9 @@ test("validateConfig defaults outputLines and accepts a positive override", () =
|
|
|
122
123
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
123
124
|
})
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
expect(issues).toEqual([])
|
|
127
|
+
expect(config.processes[0].outputLines).toBe(50)
|
|
128
|
+
expect(config.processes[1].outputLines).toBe(5)
|
|
128
129
|
})
|
|
129
130
|
|
|
130
131
|
test("validateConfig defaults the restart policy, accepts overrides, and rejects bad values", () => {
|
|
@@ -141,30 +142,30 @@ test("validateConfig defaults the restart policy, accepts overrides, and rejects
|
|
|
141
142
|
|
|
142
143
|
const defaulted = validateRestart(undefined)
|
|
143
144
|
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
expect(defaulted.issues).toEqual([])
|
|
146
|
+
expect(defaulted.config.processes[0].restart).toEqual({backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0})
|
|
146
147
|
|
|
147
148
|
const custom = validateRestart({backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
expect(custom.issues).toEqual([])
|
|
151
|
+
expect(custom.config.processes[0].restart).toEqual({backoffFactor: 2, maxDelayMs: 30000, maxRestarts: 5, windowMs: 60000})
|
|
151
152
|
|
|
152
153
|
// maxRestarts: 0 disables automatic restarts.
|
|
153
154
|
const disabled = validateRestart({maxRestarts: 0})
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
expect(disabled.issues).toEqual([])
|
|
157
|
+
expect(disabled.config.processes[0].restart.maxRestarts).toBe(0)
|
|
157
158
|
|
|
158
159
|
const invalid = validateRestart({backoffFactor: 0.5, maxDelayMs: -1, maxRestarts: -2, windowMs: -3})
|
|
159
160
|
const messages = invalid.issues.map((issue) => issue.message)
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
expect({value: Boolean(messages.includes("processes[0].restart.backoffFactor must be a number greater than or equal to 1")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
163
|
+
expect({value: Boolean(messages.includes("processes[0].restart.maxRestarts must be a non-negative integer")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
164
|
+
expect({value: Boolean(messages.includes("processes[0].restart.maxDelayMs must be a non-negative number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
165
|
+
expect({value: Boolean(messages.includes("processes[0].restart.windowMs must be a non-negative number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
165
166
|
|
|
166
167
|
// A fractional maxRestarts is rejected (it must be a whole number of restarts).
|
|
167
|
-
|
|
168
|
+
expect(validateRestart({maxRestarts: 1.5}).issues.some((issue) => issue.message === "processes[0].restart.maxRestarts must be a non-negative integer")).toBeTruthy()
|
|
168
169
|
})
|
|
169
170
|
|
|
170
171
|
test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values", () => {
|
|
@@ -180,30 +181,30 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
|
|
|
180
181
|
})
|
|
181
182
|
|
|
182
183
|
// Omitted → no commands, zero drain.
|
|
183
|
-
|
|
184
|
+
expect(validateLifecycle(undefined).config.processes[0].lifecycle).toEqual({activateTimeoutMs: 30000, drainTimeoutMs: 0})
|
|
184
185
|
|
|
185
186
|
const custom = validateLifecycle({activateTimeoutMs: 60000, drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
|
|
186
187
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
expect(custom.issues).toEqual([])
|
|
189
|
+
expect(custom.config.processes[0].lifecycle.quietCommand).toBe("kill -TSTP $ROLLBRIDGE_PID")
|
|
190
|
+
expect(custom.config.processes[0].lifecycle.stopCommand).toBe("kill -TERM $ROLLBRIDGE_PID")
|
|
191
|
+
expect(custom.config.processes[0].lifecycle.activateTimeoutMs).toBe(60000)
|
|
192
|
+
expect(custom.config.processes[0].lifecycle.drainTimeoutMs).toBe(30000)
|
|
192
193
|
|
|
193
194
|
const invalid = validateLifecycle({activateTimeoutMs: 0, drainTimeoutMs: -1, quietCommand: 5})
|
|
194
195
|
const messages = invalid.issues.map((issue) => issue.message)
|
|
195
196
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
expect({value: Boolean(messages.includes("processes[0].lifecycle.activateTimeoutMs must be a positive number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
198
|
+
expect({value: Boolean(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
199
|
+
expect({value: Boolean(messages.includes("processes[0].lifecycle.quietCommand must be a string")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
199
200
|
|
|
200
201
|
// drainCommand needs a positive drainTimeoutMs to bound it; otherwise the drain step is skipped.
|
|
201
|
-
|
|
202
|
-
.some((issue) => issue.message === "processes[0].lifecycle.drainCommand requires a positive processes[0].lifecycle.drainTimeoutMs"))
|
|
203
|
-
|
|
202
|
+
expect(validateLifecycle({drainCommand: "drain"}).issues
|
|
203
|
+
.some((issue) => issue.message === "processes[0].lifecycle.drainCommand requires a positive processes[0].lifecycle.drainTimeoutMs")).toBeTruthy()
|
|
204
|
+
expect(validateLifecycle({drainCommand: "drain", drainTimeoutMs: 1000}).issues).toEqual([])
|
|
204
205
|
|
|
205
206
|
// A stopCommand replaces stopSignal, so a stopCommand alongside the default SIGTERM is fine.
|
|
206
|
-
|
|
207
|
+
expect(validateLifecycle({stopCommand: "kill -TERM $ROLLBRIDGE_PID"}).issues).toEqual([])
|
|
207
208
|
})
|
|
208
209
|
|
|
209
210
|
test("validateConfig accepts one durable handoff activation lifecycle and rejects unsafe placements", () => {
|
|
@@ -227,31 +228,31 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
|
|
|
227
228
|
}
|
|
228
229
|
const valid = validateConfig(base)
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
expect(valid.issues).toEqual([])
|
|
232
|
+
expect(valid.config.processes[1].lifecycle.activateCommand).toBe("jobs activate")
|
|
232
233
|
|
|
233
234
|
const invalidType = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], lifecycle: {activateCommand: 5, quietCommand: "jobs retire"}}]})
|
|
234
|
-
|
|
235
|
+
expect(invalidType.issues.some((issue) => issue.message === "processes[1].lifecycle.activateCommand must be a string")).toBeTruthy()
|
|
235
236
|
|
|
236
237
|
const emptyCommands = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], lifecycle: {activateCommand: " ", quietCommand: ""}}]})
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
expect(emptyCommands.issues.some((issue) => issue.message === "processes[1].lifecycle.activateCommand must not be empty")).toBeTruthy()
|
|
239
|
+
expect(emptyCommands.issues.some((issue) => issue.message === "processes[1].lifecycle.quietCommand must not be empty")).toBeTruthy()
|
|
239
240
|
|
|
240
241
|
const missingRetirement = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], lifecycle: {activateCommand: "jobs activate"}}]})
|
|
241
|
-
|
|
242
|
+
expect(missingRetirement.issues.some((issue) => /requires lifecycle\.quietCommand/.test(issue.message))).toBeTruthy()
|
|
242
243
|
|
|
243
244
|
const nonHandoff = validateConfig({...base, processes: [base.processes[0], {...base.processes[1], deployStrategy: "persistent"}]})
|
|
244
|
-
|
|
245
|
+
expect(nonHandoff.issues.some((issue) => /activateCommand.*handoff service/.test(issue.message))).toBeTruthy()
|
|
245
246
|
|
|
246
247
|
const withoutRecovery = validateConfig({...base, ownerRecovery: undefined, statePath: undefined})
|
|
247
|
-
|
|
248
|
+
expect(withoutRecovery.issues.some((issue) => /activateCommand requires ownerRecovery and statePath/.test(issue.message))).toBeTruthy()
|
|
248
249
|
|
|
249
250
|
const duplicate = validateConfig({...base, processes: [
|
|
250
251
|
base.processes[0],
|
|
251
252
|
base.processes[1],
|
|
252
253
|
{...base.processes[1], id: "jobs-secondary", port: {from: 18200, to: 18299}}
|
|
253
254
|
]})
|
|
254
|
-
|
|
255
|
+
expect(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message))).toBeTruthy()
|
|
255
256
|
|
|
256
257
|
const worker = {
|
|
257
258
|
command: "run worker",
|
|
@@ -262,14 +263,14 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
|
|
|
262
263
|
}
|
|
263
264
|
const pairedWorker = validateConfig({...base, processes: [...base.processes, worker]})
|
|
264
265
|
|
|
265
|
-
|
|
266
|
-
|
|
266
|
+
expect(pairedWorker.issues).toEqual([])
|
|
267
|
+
expect(pairedWorker.config.processes[2].lifecycle.reactivateCommand).toBe("worker resume")
|
|
267
268
|
|
|
268
269
|
const unpairedWorker = validateConfig({...base, processes: [...base.processes, {...worker, lifecycle: {quietCommand: "worker quiet"}}]})
|
|
269
|
-
|
|
270
|
+
expect(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message))).toBeTruthy()
|
|
270
271
|
|
|
271
272
|
const unsupportedPlacement = validateConfig({...base, processes: [...base.processes, {...worker, nonBlockingDrain: false}]})
|
|
272
|
-
|
|
273
|
+
expect(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message))).toBeTruthy()
|
|
273
274
|
})
|
|
274
275
|
|
|
275
276
|
test("validateConfig accepts indefinite graceful stop windows", () => {
|
|
@@ -283,8 +284,8 @@ test("validateConfig accepts indefinite graceful stop windows", () => {
|
|
|
283
284
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
284
285
|
})
|
|
285
286
|
|
|
286
|
-
|
|
287
|
-
|
|
287
|
+
expect(issues).toEqual([])
|
|
288
|
+
expect(config.processes[1].gracefulStopMs).toBe("indefinite")
|
|
288
289
|
})
|
|
289
290
|
|
|
290
291
|
test("validateConfig accepts handoff services only with a multi-port service range", () => {
|
|
@@ -298,8 +299,8 @@ test("validateConfig accepts handoff services only with a multi-port service ran
|
|
|
298
299
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
299
300
|
})
|
|
300
301
|
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
expect(valid.issues).toEqual([])
|
|
303
|
+
expect(valid.config.processes[1].deployStrategy).toBe("handoff")
|
|
303
304
|
|
|
304
305
|
const defaulted = validateConfig({
|
|
305
306
|
application: "demo",
|
|
@@ -308,7 +309,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
|
|
|
308
309
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
309
310
|
})
|
|
310
311
|
|
|
311
|
-
|
|
312
|
+
expect(defaulted.config.processes[0].deployStrategy).toBe("persistent")
|
|
312
313
|
|
|
313
314
|
const invalidProcess = validateConfig({
|
|
314
315
|
application: "demo",
|
|
@@ -320,7 +321,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
|
|
|
320
321
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
321
322
|
})
|
|
322
323
|
|
|
323
|
-
|
|
324
|
+
expect({value: Boolean(invalidProcess.issues.some((issue) => issue.message === "Process \"worker\" can only set deployStrategy: \"handoff\" on a service process")), context: JSON.stringify(invalidProcess.issues)}).toMatchObject({value: true})
|
|
324
325
|
|
|
325
326
|
const missingPort = validateConfig({
|
|
326
327
|
application: "demo",
|
|
@@ -332,7 +333,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
|
|
|
332
333
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
333
334
|
})
|
|
334
335
|
|
|
335
|
-
|
|
336
|
+
expect({value: Boolean(missingPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must define a port range")), context: JSON.stringify(missingPort.issues)}).toMatchObject({value: true})
|
|
336
337
|
|
|
337
338
|
const fixedPort = validateConfig({
|
|
338
339
|
application: "demo",
|
|
@@ -344,7 +345,7 @@ test("validateConfig accepts handoff services only with a multi-port service ran
|
|
|
344
345
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
345
346
|
})
|
|
346
347
|
|
|
347
|
-
|
|
348
|
+
expect({value: Boolean(fixedPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must use a multi-port range")), context: JSON.stringify(fixedPort.issues)}).toMatchObject({value: true})
|
|
348
349
|
})
|
|
349
350
|
|
|
350
351
|
test("validateConfig rejects a custom stopSignal alongside a stopCommand that would ignore it", () => {
|
|
@@ -360,18 +361,17 @@ test("validateConfig rejects a custom stopSignal alongside a stopCommand that wo
|
|
|
360
361
|
})
|
|
361
362
|
|
|
362
363
|
// A custom stopSignal with a stopCommand is contradictory: stopCommand runs instead of the signal.
|
|
363
|
-
|
|
364
|
-
.some((issue) => /sets both lifecycle.stopCommand and a custom stopSignal/.test(issue.message))
|
|
365
|
-
"a custom stopSignal next to a stopCommand must be rejected")
|
|
364
|
+
expect(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGINT"}).issues
|
|
365
|
+
.some((issue) => /sets both lifecycle.stopCommand and a custom stopSignal/.test(issue.message))).toBe(true)
|
|
366
366
|
|
|
367
367
|
// stopSignal alone (no stopCommand) is fine — the signal is what stops the worker.
|
|
368
|
-
|
|
368
|
+
expect(validateWorker({stopSignal: "SIGINT"}).issues).toEqual([])
|
|
369
369
|
|
|
370
370
|
// stopCommand alone (default SIGTERM) is fine — nothing custom is silently dropped.
|
|
371
|
-
|
|
371
|
+
expect(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}}).issues).toEqual([])
|
|
372
372
|
|
|
373
373
|
// An explicit default stopSignal next to a stopCommand is not flagged (SIGTERM is the default).
|
|
374
|
-
|
|
374
|
+
expect(validateWorker({lifecycle: {stopCommand: "kill -TERM $ROLLBRIDGE_PID"}, stopSignal: "SIGTERM"}).issues).toEqual([])
|
|
375
375
|
})
|
|
376
376
|
|
|
377
377
|
test("validateConfig defaults replicas, accepts companion replicas, and rejects bad placements", () => {
|
|
@@ -386,39 +386,39 @@ test("validateConfig defaults replicas, accepts companion replicas, and rejects
|
|
|
386
386
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
387
387
|
})
|
|
388
388
|
|
|
389
|
-
|
|
389
|
+
expect(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].replicas).toBe(1)
|
|
390
390
|
|
|
391
391
|
const replicated = validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 4})
|
|
392
392
|
|
|
393
|
-
|
|
394
|
-
|
|
393
|
+
expect(replicated.issues).toEqual([])
|
|
394
|
+
expect(replicated.config.processes[1].replicas).toBe(4)
|
|
395
395
|
|
|
396
396
|
// replicas > 1 on a companion with a port is rejected.
|
|
397
|
-
|
|
398
|
-
.some((issue) => /can only set replicas > 1 on a companion process without a port/.test(issue.message)))
|
|
397
|
+
expect(validateWorker({command: "run worker", id: "worker", policy: "companion", port: {from: 19000, to: 19099}, replicas: 2}).issues
|
|
398
|
+
.some((issue) => /can only set replicas > 1 on a companion process without a port/.test(issue.message))).toBeTruthy()
|
|
399
399
|
|
|
400
400
|
// replicas > 1 on a non-companion policy is rejected.
|
|
401
|
-
|
|
402
|
-
.some((issue) => /can only set replicas > 1 on a companion/.test(issue.message)))
|
|
401
|
+
expect(validateWorker({command: "run broker", id: "broker", policy: "service", replicas: 2}).issues
|
|
402
|
+
.some((issue) => /can only set replicas > 1 on a companion/.test(issue.message))).toBeTruthy()
|
|
403
403
|
|
|
404
404
|
// Non-positive replicas is rejected.
|
|
405
|
-
|
|
406
|
-
.some((issue) => issue.message === "processes[1].replicas must be a positive integer"))
|
|
405
|
+
expect(validateWorker({command: "run worker", id: "worker", policy: "companion", replicas: 0}).issues
|
|
406
|
+
.some((issue) => issue.message === "processes[1].replicas must be a positive integer")).toBeTruthy()
|
|
407
407
|
|
|
408
408
|
// A "#" in a process id (reserved for replica instance ids) is rejected.
|
|
409
|
-
|
|
410
|
-
.some((issue) => /must not contain "#"/.test(issue.message)))
|
|
409
|
+
expect(validateWorker({command: "run worker", id: "work#er", policy: "companion"}).issues
|
|
410
|
+
.some((issue) => /must not contain "#"/.test(issue.message))).toBeTruthy()
|
|
411
411
|
|
|
412
412
|
// nonBlockingDrain defaults to false, is accepted on a companion, and rejected elsewhere.
|
|
413
|
-
|
|
413
|
+
expect(validateWorker({command: "run worker", id: "worker", policy: "companion"}).config.processes[1].nonBlockingDrain).toBe(false)
|
|
414
414
|
|
|
415
415
|
const draining = validateWorker({command: "run worker", id: "worker", nonBlockingDrain: true, policy: "companion"})
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
417
|
+
expect(draining.issues).toEqual([])
|
|
418
|
+
expect(draining.config.processes[1].nonBlockingDrain).toBe(true)
|
|
419
419
|
|
|
420
|
-
|
|
421
|
-
.some((issue) => /can only set nonBlockingDrain on a companion/.test(issue.message)))
|
|
420
|
+
expect(validateWorker({command: "run b", id: "broker", nonBlockingDrain: true, policy: "service"}).issues
|
|
421
|
+
.some((issue) => /can only set nonBlockingDrain on a companion/.test(issue.message))).toBeTruthy()
|
|
422
422
|
})
|
|
423
423
|
|
|
424
424
|
test("validateConfig defaults stopSignal, accepts valid signals, and rejects unknown ones", () => {
|
|
@@ -433,16 +433,16 @@ test("validateConfig defaults stopSignal, accepts valid signals, and rejects unk
|
|
|
433
433
|
proxy: {host: "127.0.0.1", port: 8182}
|
|
434
434
|
})
|
|
435
435
|
|
|
436
|
-
|
|
436
|
+
expect(validateStopSignal(undefined).config.processes[0].stopSignal).toBe("SIGTERM")
|
|
437
437
|
|
|
438
438
|
const custom = validateStopSignal("SIGINT")
|
|
439
439
|
|
|
440
|
-
|
|
441
|
-
|
|
440
|
+
expect(custom.issues).toEqual([])
|
|
441
|
+
expect(custom.config.processes[0].stopSignal).toBe("SIGINT")
|
|
442
442
|
|
|
443
443
|
const invalid = validateStopSignal("SIGBOGUS")
|
|
444
444
|
|
|
445
|
-
|
|
445
|
+
expect({value: Boolean(invalid.issues.some((issue) => issue.message === "processes[0].stopSignal must be a valid signal name")), context: JSON.stringify(invalid.issues.map((issue) => issue.message))}).toMatchObject({value: true})
|
|
446
446
|
})
|
|
447
447
|
|
|
448
448
|
test("validateConfig normalizes memory supervision and rejects bad values", () => {
|
|
@@ -458,25 +458,25 @@ test("validateConfig normalizes memory supervision and rejects bad values", () =
|
|
|
458
458
|
})
|
|
459
459
|
|
|
460
460
|
// Omitted → monitoring off.
|
|
461
|
-
|
|
461
|
+
expect(validateMemory(undefined).config.processes[0].memory).toBe(undefined)
|
|
462
462
|
|
|
463
463
|
const custom = validateMemory({checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
|
|
464
464
|
|
|
465
|
-
|
|
466
|
-
|
|
465
|
+
expect(custom.issues).toEqual([])
|
|
466
|
+
expect(custom.config.processes[0].memory).toEqual({checkIntervalMs: 2000, limitBytes: 1048576, warnBytes: 524288})
|
|
467
467
|
|
|
468
468
|
// Defaults checkIntervalMs and warnBytes when only limitBytes is given.
|
|
469
469
|
const defaulted = validateMemory({limitBytes: 1048576})
|
|
470
470
|
|
|
471
|
-
|
|
472
|
-
|
|
471
|
+
expect(defaulted.issues).toEqual([])
|
|
472
|
+
expect(defaulted.config.processes[0].memory).toEqual({checkIntervalMs: 5000, limitBytes: 1048576, warnBytes: 0})
|
|
473
473
|
|
|
474
474
|
const invalid = validateMemory({checkIntervalMs: 0, limitBytes: 0, warnBytes: -1})
|
|
475
475
|
const messages = invalid.issues.map((issue) => issue.message)
|
|
476
476
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
477
|
+
expect({value: Boolean(messages.includes("processes[0].memory.limitBytes must be a positive integer")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
478
|
+
expect({value: Boolean(messages.includes("processes[0].memory.warnBytes must be a non-negative integer")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
479
|
+
expect({value: Boolean(messages.includes("processes[0].memory.checkIntervalMs must be a positive number")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
480
480
|
})
|
|
481
481
|
|
|
482
482
|
test("validateConfig rejects a non-positive-integer outputLines with a fix", () => {
|
|
@@ -491,8 +491,8 @@ test("validateConfig rejects a non-positive-integer outputLines with a fix", ()
|
|
|
491
491
|
|
|
492
492
|
const issue = issues.find((candidate) => candidate.message === "processes[0].outputLines must be a positive integer")
|
|
493
493
|
|
|
494
|
-
|
|
495
|
-
|
|
494
|
+
if (!issue) throw new Error(`expected an outputLines issue in ${JSON.stringify(issues.map((candidate) => candidate.message))}`)
|
|
495
|
+
expect(issue.fix).toMatch(/positive integer/)
|
|
496
496
|
})
|
|
497
497
|
|
|
498
498
|
test("validateConfig parses control.mode, defaults it to unset, and rejects invalid modes", () => {
|
|
@@ -509,20 +509,20 @@ test("validateConfig parses control.mode, defaults it to unset, and rejects inva
|
|
|
509
509
|
|
|
510
510
|
const parsed = validateControl({mode: "660", path: "/tmp/demo.sock"})
|
|
511
511
|
|
|
512
|
-
|
|
513
|
-
|
|
512
|
+
expect(parsed.issues).toEqual([])
|
|
513
|
+
expect(parsed.config.control.mode).toBe(0o660)
|
|
514
514
|
|
|
515
515
|
// Minimal octal strings are accepted, matching the numeric boundary (e.g. 0).
|
|
516
516
|
const minimal = validateControl({mode: "0", path: "/tmp/demo.sock"})
|
|
517
517
|
|
|
518
|
-
|
|
519
|
-
|
|
518
|
+
expect(minimal.issues).toEqual([])
|
|
519
|
+
expect(minimal.config.control.mode).toBe(0)
|
|
520
520
|
|
|
521
|
-
|
|
521
|
+
expect(validateControl({path: "/tmp/demo.sock"}).config.control.mode).toBe(undefined)
|
|
522
522
|
|
|
523
523
|
const invalid = validateControl({mode: "abc", path: "/tmp/demo.sock"})
|
|
524
524
|
|
|
525
|
-
|
|
525
|
+
expect(invalid.issues.some((issue) => issue.message === "control.mode must be an octal file mode between 0 and 0o777")).toBeTruthy()
|
|
526
526
|
})
|
|
527
527
|
|
|
528
528
|
test("validateConfig accepts control owner/group as ids or names and rejects bad values", () => {
|
|
@@ -539,25 +539,25 @@ test("validateConfig accepts control owner/group as ids or names and rejects bad
|
|
|
539
539
|
|
|
540
540
|
const numeric = validateControl({group: 1000, owner: 1000, path: "/tmp/demo.sock"})
|
|
541
541
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
542
|
+
expect(numeric.issues).toEqual([])
|
|
543
|
+
expect(numeric.config.control.owner).toBe(1000)
|
|
544
|
+
expect(numeric.config.control.group).toBe(1000)
|
|
545
545
|
|
|
546
546
|
const named = validateControl({group: "deploy", owner: "deploy", path: "/tmp/demo.sock"})
|
|
547
547
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
548
|
+
expect(named.issues).toEqual([])
|
|
549
|
+
expect(named.config.control.owner).toBe("deploy")
|
|
550
|
+
expect(named.config.control.group).toBe("deploy")
|
|
551
551
|
|
|
552
552
|
// Unset by default.
|
|
553
|
-
|
|
554
|
-
|
|
553
|
+
expect(validateControl({path: "/tmp/demo.sock"}).config.control.owner).toBe(undefined)
|
|
554
|
+
expect(validateControl({path: "/tmp/demo.sock"}).config.control.group).toBe(undefined)
|
|
555
555
|
|
|
556
556
|
const invalid = validateControl({group: -1, owner: true, path: "/tmp/demo.sock"})
|
|
557
557
|
const messages = invalid.issues.map((issue) => issue.message)
|
|
558
558
|
|
|
559
|
-
|
|
560
|
-
|
|
559
|
+
expect({value: Boolean(messages.includes("control.owner must be a non-negative integer id or a name")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
560
|
+
expect({value: Boolean(messages.includes("control.group must be a non-negative integer id or a name")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
561
561
|
})
|
|
562
562
|
|
|
563
563
|
test("validateConfig defaults health.startDelayMs to 0, accepts an override, and rejects negatives", () => {
|
|
@@ -574,17 +574,17 @@ test("validateConfig defaults health.startDelayMs to 0, accepts an override, and
|
|
|
574
574
|
|
|
575
575
|
const defaulted = validateHealth({path: "/ping"})
|
|
576
576
|
|
|
577
|
-
|
|
578
|
-
|
|
577
|
+
expect(defaulted.issues).toEqual([])
|
|
578
|
+
expect(defaulted.config.processes[0].health?.startDelayMs).toBe(0)
|
|
579
579
|
|
|
580
580
|
const custom = validateHealth({path: "/ping", startDelayMs: 2000})
|
|
581
581
|
|
|
582
|
-
|
|
583
|
-
|
|
582
|
+
expect(custom.issues).toEqual([])
|
|
583
|
+
expect(custom.config.processes[0].health?.startDelayMs).toBe(2000)
|
|
584
584
|
|
|
585
585
|
const negative = validateHealth({path: "/ping", startDelayMs: -1})
|
|
586
586
|
|
|
587
|
-
|
|
587
|
+
expect(negative.issues.some((issue) => issue.message === "processes[0].health.startDelayMs must be a non-negative number")).toBeTruthy()
|
|
588
588
|
})
|
|
589
589
|
|
|
590
590
|
test("validateConfig defaults releaseRetention, accepts overrides, and rejects bad values", () => {
|
|
@@ -602,18 +602,18 @@ test("validateConfig defaults releaseRetention, accepts overrides, and rejects b
|
|
|
602
602
|
|
|
603
603
|
const defaulted = validateRetention(undefined)
|
|
604
604
|
|
|
605
|
-
|
|
606
|
-
|
|
605
|
+
expect(defaulted.issues).toEqual([])
|
|
606
|
+
expect(defaulted.config.releaseRetention).toEqual({keep: 10, maxAgeMs: 0})
|
|
607
607
|
|
|
608
608
|
const custom = validateRetention({keep: 3, maxAgeMs: 60000})
|
|
609
609
|
|
|
610
|
-
|
|
611
|
-
|
|
610
|
+
expect(custom.issues).toEqual([])
|
|
611
|
+
expect(custom.config.releaseRetention).toEqual({keep: 3, maxAgeMs: 60000})
|
|
612
612
|
|
|
613
613
|
const invalid = validateRetention({keep: -1, maxAgeMs: -5})
|
|
614
614
|
|
|
615
|
-
|
|
616
|
-
|
|
615
|
+
expect(invalid.issues.some((issue) => issue.message === "releaseRetention.keep must be a non-negative integer")).toBeTruthy()
|
|
616
|
+
expect(invalid.issues.some((issue) => issue.message === "releaseRetention.maxAgeMs must be a non-negative number")).toBeTruthy()
|
|
617
617
|
})
|
|
618
618
|
|
|
619
619
|
test("validateConfig leaves statePath unset by default, accepts a string, and rejects non-strings", () => {
|
|
@@ -629,14 +629,14 @@ test("validateConfig leaves statePath unset by default, accepts a string, and re
|
|
|
629
629
|
statePath
|
|
630
630
|
})
|
|
631
631
|
|
|
632
|
-
|
|
632
|
+
expect(validateStatePath(undefined).config.statePath).toBe(undefined)
|
|
633
633
|
|
|
634
634
|
const set = validateStatePath("/var/lib/rollbridge/demo.state.json")
|
|
635
635
|
|
|
636
|
-
|
|
637
|
-
|
|
636
|
+
expect(set.issues).toEqual([])
|
|
637
|
+
expect(set.config.statePath).toBe("/var/lib/rollbridge/demo.state.json")
|
|
638
638
|
|
|
639
|
-
|
|
639
|
+
expect(validateStatePath(123).issues.some((issue) => issue.message === "statePath must be a string")).toBeTruthy()
|
|
640
640
|
})
|
|
641
641
|
|
|
642
642
|
test("ownerRecovery requires durable state and a non-negative integer reconnection grace", () => {
|
|
@@ -649,36 +649,31 @@ test("ownerRecovery requires durable state and a non-negative integer reconnecti
|
|
|
649
649
|
}
|
|
650
650
|
const missingState = validateConfig(raw)
|
|
651
651
|
|
|
652
|
-
|
|
652
|
+
expect(missingState.issues.some((issue) => issue.message === "ownerRecovery requires statePath")).toBeTruthy()
|
|
653
653
|
|
|
654
654
|
const valid = validateConfig({...raw, statePath: "/var/lib/rollbridge/demo.state.json"})
|
|
655
655
|
|
|
656
|
-
|
|
657
|
-
|
|
656
|
+
expect(valid.issues).toEqual([])
|
|
657
|
+
expect(valid.config.ownerRecovery).toEqual({reconnectGraceMs: 45000})
|
|
658
658
|
|
|
659
659
|
const invalidGrace = validateConfig({...raw, ownerRecovery: {reconnectGraceMs: -1}, statePath: "/var/lib/rollbridge/demo.state.json"})
|
|
660
660
|
|
|
661
|
-
|
|
661
|
+
expect(invalidGrace.issues.some((issue) => issue.message === "ownerRecovery.reconnectGraceMs must be a non-negative integer")).toBeTruthy()
|
|
662
662
|
})
|
|
663
663
|
|
|
664
|
-
test("normalizeConfig throws an aggregated error listing every issue", () => {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
assert.match(error.message, /exactly one proxied process; found 2/)
|
|
678
|
-
|
|
679
|
-
return true
|
|
680
|
-
}
|
|
681
|
-
)
|
|
664
|
+
test("normalizeConfig throws an aggregated error listing every issue", async () => {
|
|
665
|
+
const normalization = Promise.resolve().then(() => normalizeConfig({
|
|
666
|
+
application: "demo",
|
|
667
|
+
processes: [
|
|
668
|
+
{command: "run web", id: "web", policy: "proxied"},
|
|
669
|
+
{command: "run web", id: "web", policy: "proxied"}
|
|
670
|
+
],
|
|
671
|
+
proxy: {port: 8182}
|
|
672
|
+
}))
|
|
673
|
+
|
|
674
|
+
await expect(normalization).rejects.toBeInstanceOf(Error)
|
|
675
|
+
await expect(normalization).rejects.toMatchObject({message: expect.stringMatching(/Duplicate process id: web/)})
|
|
676
|
+
await expect(normalization).rejects.toMatchObject({message: expect.stringMatching(/exactly one proxied process; found 2/)})
|
|
682
677
|
})
|
|
683
678
|
|
|
684
679
|
test("validate CLI command reports every issue with a fix and exits non-zero", async () => {
|
|
@@ -693,9 +688,9 @@ test("validate CLI command reports every issue with a fix and exits non-zero", a
|
|
|
693
688
|
try {
|
|
694
689
|
const {output} = await captureCli(["node", "rollbridge", "validate", "-c", configPath])
|
|
695
690
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
691
|
+
expect(process.exitCode).toBe(1)
|
|
692
|
+
expect(output).toMatch(/Proxied process web must define a port range/)
|
|
693
|
+
expect(output).toMatch(/Fix: Add a port range to the proxied process "web"/)
|
|
699
694
|
} finally {
|
|
700
695
|
process.exitCode = 0
|
|
701
696
|
await fs.rm(path.dirname(configPath), {force: true, recursive: true})
|
|
@@ -715,8 +710,8 @@ test("validate CLI command accepts a valid config without setting a failure exit
|
|
|
715
710
|
try {
|
|
716
711
|
const {output} = await captureCli(["node", "rollbridge", "validate", "-c", configPath])
|
|
717
712
|
|
|
718
|
-
|
|
719
|
-
|
|
713
|
+
expect(process.exitCode).not.toBe(1)
|
|
714
|
+
expect(output).toMatch(/is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
|
|
720
715
|
} finally {
|
|
721
716
|
await fs.rm(path.dirname(configPath), {force: true, recursive: true})
|
|
722
717
|
}
|
|
@@ -738,17 +733,17 @@ test("validate --json emits a machine-readable result", async () => {
|
|
|
738
733
|
try {
|
|
739
734
|
const valid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", validPath])).output)
|
|
740
735
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
736
|
+
expect(valid.valid).toBe(true)
|
|
737
|
+
expect(valid.issues).toEqual([])
|
|
738
|
+
expect(valid.config.processes).toBe(1)
|
|
739
|
+
expect(process.exitCode).not.toBe(1)
|
|
745
740
|
|
|
746
741
|
const invalid = JSON.parse((await captureCli(["node", "rollbridge", "validate", "--json", "-c", invalidPath])).output)
|
|
747
742
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
743
|
+
expect(invalid.valid).toBe(false)
|
|
744
|
+
expect(invalid.config).toBe(null)
|
|
745
|
+
expect(invalid.issues.some((/** @type {{message: string}} */ issue) => /must define a port range/.test(issue.message))).toBeTruthy()
|
|
746
|
+
expect(process.exitCode).toBe(1)
|
|
752
747
|
} finally {
|
|
753
748
|
process.exitCode = 0
|
|
754
749
|
await fs.rm(path.dirname(validPath), {force: true, recursive: true})
|
|
@@ -794,3 +789,4 @@ async function captureCli(argv) {
|
|
|
794
789
|
|
|
795
790
|
return {output: lines.join("\n")}
|
|
796
791
|
}
|
|
792
|
+
})
|