rollbridge 0.1.54 → 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/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- 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 +22 -21
- 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 +140 -148
- package/test/health.test.js +6 -4
- package/test/logs.test.js +17 -18
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +226 -239
- package/test/owner-replacement.test.js +227 -222
- 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 +377 -396
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
package/test/rollbridge.test.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import {spawn} from "node:child_process"
|
|
5
4
|
import {once} from "node:events"
|
|
6
5
|
import fs from "node:fs/promises"
|
|
7
6
|
import net from "node:net"
|
|
8
7
|
import os from "node:os"
|
|
9
8
|
import path from "node:path"
|
|
10
|
-
import test from "
|
|
9
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
11
10
|
import {fileURLToPath, pathToFileURL} from "node:url"
|
|
12
11
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
13
12
|
import {normalizeConfig} from "../src/config.js"
|
|
@@ -15,6 +14,8 @@ import {sendControlCommand} from "../src/control-client.js"
|
|
|
15
14
|
import {liveProcesses, readState, writeState} from "../src/state-store.js"
|
|
16
15
|
import {runCli} from "../src/cli.js"
|
|
17
16
|
|
|
17
|
+
describe("rollbridge", () => {
|
|
18
|
+
|
|
18
19
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
19
20
|
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
20
21
|
const dependentAppPath = path.join(currentDir, "fixtures", "dependent-app.js")
|
|
@@ -22,6 +23,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
|
22
23
|
const memoryHogPath = path.join(currentDir, "fixtures", "memory-hog.js")
|
|
23
24
|
const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
|
|
24
25
|
const singletonAppPath = path.join(currentDir, "fixtures", "singleton-app.js")
|
|
26
|
+
const linuxTest = process.platform === "linux" ? test : test.skip
|
|
25
27
|
|
|
26
28
|
test("a nonBlockingDrain worker stops immediately while its release is still draining", async () => {
|
|
27
29
|
const fixture = await createFixture({nonBlockingDrainWorker: true})
|
|
@@ -47,9 +49,9 @@ test("a nonBlockingDrain worker stops immediately while its release is still dra
|
|
|
47
49
|
|
|
48
50
|
// The release is still draining (the WebSocket is held) and its proxied process is still
|
|
49
51
|
// serving, but the worker has already drained.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
expect(v1.state).toBe("draining")
|
|
53
|
+
expect(v1.processes.find((processStatus) => processStatus.id === "web")?.state).toBe("running")
|
|
54
|
+
expect(v1.processes.find((processStatus) => processStatus.id === "worker")?.state).toBe("stopped")
|
|
53
55
|
} finally {
|
|
54
56
|
if (socket) socket.close()
|
|
55
57
|
await daemon.shutdown()
|
|
@@ -63,16 +65,16 @@ test("deploy switches new HTTP traffic while old WebSockets drain", async () =>
|
|
|
63
65
|
|
|
64
66
|
try {
|
|
65
67
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
66
|
-
|
|
68
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
67
69
|
|
|
68
70
|
const websocket = await openWebSocket(daemon)
|
|
69
71
|
|
|
70
72
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
71
|
-
|
|
73
|
+
expect(await fetchText(daemon, "/release")).toBe("v2")
|
|
72
74
|
|
|
73
75
|
const drainingRelease = statusRelease(daemon, "v1")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
expect(drainingRelease.state).toBe("draining")
|
|
77
|
+
expect(drainingRelease.connections.websocket).toBe(1)
|
|
76
78
|
|
|
77
79
|
websocket.close()
|
|
78
80
|
await waitFor(async () => statusRelease(daemon, "v1").state === "stopped")
|
|
@@ -89,13 +91,10 @@ test("failed health check leaves the previous release active", async () => {
|
|
|
89
91
|
try {
|
|
90
92
|
await daemon.deploy({releaseId: "good", releasePath: fixture.root, revision: "good"})
|
|
91
93
|
|
|
92
|
-
await
|
|
93
|
-
() => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}),
|
|
94
|
-
/Health check failed/
|
|
95
|
-
)
|
|
94
|
+
await expect(daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"})).rejects.toThrow(/Health check failed/)
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
expect(await fetchText(daemon, "/release")).toBe("good")
|
|
97
|
+
expect(daemon.status().activeReleaseId).toBe("good")
|
|
99
98
|
} finally {
|
|
100
99
|
await daemon.shutdown()
|
|
101
100
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -128,8 +127,8 @@ test("deploy reloads process config and retires the previous worker with the ref
|
|
|
128
127
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
129
128
|
await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped", 1000)
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
expect(daemon.config.processes.find((processConfig) => processConfig.id === "worker")?.gracefulStopMs).toBe(50)
|
|
131
|
+
expect(await fetchText(daemon, "/release")).toBe("v2")
|
|
133
132
|
} finally {
|
|
134
133
|
await daemon.shutdown()
|
|
135
134
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -149,12 +148,9 @@ test("deploy rejects a reloaded config that changes the running proxy", async ()
|
|
|
149
148
|
proxy: {...fixture.config.proxy, host: "0.0.0.0"}
|
|
150
149
|
}), fixture.root)
|
|
151
150
|
|
|
152
|
-
await
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
157
|
-
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
151
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/proxy\.host.*restart the Rollbridge daemon/)
|
|
152
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
153
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
158
154
|
} finally {
|
|
159
155
|
await daemon.shutdown()
|
|
160
156
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -178,14 +174,11 @@ test("a failed deploy does not adopt reloaded process config", async () => {
|
|
|
178
174
|
})
|
|
179
175
|
|
|
180
176
|
await writeConfigFile(failingConfig, fixture.root)
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "web")?.health?.path, "/ping")
|
|
187
|
-
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
188
|
-
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
177
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/Health check failed/)
|
|
178
|
+
|
|
179
|
+
expect(daemon.config.processes.find((processConfig) => processConfig.id === "web")?.health?.path).toBe("/ping")
|
|
180
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
181
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
189
182
|
} finally {
|
|
190
183
|
await daemon.shutdown()
|
|
191
184
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -202,11 +195,11 @@ test("wildcard proxy bind host targets release processes through loopback", asyn
|
|
|
202
195
|
const status = daemon.status()
|
|
203
196
|
const release = statusRelease(daemon, "v1")
|
|
204
197
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
198
|
+
if (!daemon.activeRelease) throw new Error("expected active release")
|
|
199
|
+
expect(status.proxy.host).toBe("0.0.0.0")
|
|
200
|
+
expect(status.proxy.upstreamHost).toBe("127.0.0.1")
|
|
201
|
+
expect(daemon.activeRelease.proxyTarget().target).toBe(`http://127.0.0.1:${release.ports.web}`)
|
|
202
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
210
203
|
} finally {
|
|
211
204
|
await daemon.shutdown()
|
|
212
205
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -225,27 +218,24 @@ test("failed release startup logs process output and cleanup status", async () =
|
|
|
225
218
|
await daemon.start()
|
|
226
219
|
|
|
227
220
|
try {
|
|
228
|
-
await
|
|
229
|
-
() => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}),
|
|
230
|
-
/Health check failed/
|
|
231
|
-
)
|
|
221
|
+
await expect(daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"})).rejects.toThrow(/Health check failed/)
|
|
232
222
|
|
|
233
223
|
const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "before cleanup" && entry.data?.processId === "web")
|
|
234
224
|
const cleanupProcessStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "web")
|
|
235
225
|
const handoffServiceStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "beacon")
|
|
236
226
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
227
|
+
if (!processStatusLog) throw new Error("expected failed web process diagnostics to be logged")
|
|
228
|
+
if (!processStatusLog.data) throw new Error("expected diagnostic data")
|
|
229
|
+
if (!Array.isArray(processStatusLog.data.logs)) throw new Error("expected retained process output in diagnostics")
|
|
230
|
+
expect(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stdout")).toBeTruthy()
|
|
231
|
+
expect(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stderr")).toBeTruthy()
|
|
232
|
+
expect(processStatusLog.data.state).toBe("running")
|
|
233
|
+
if (!cleanupProcessStatusLog) throw new Error("expected failed web cleanup diagnostics to be logged")
|
|
234
|
+
expect(cleanupProcessStatusLog.data?.state).toBe("stopped")
|
|
235
|
+
expect(cleanupProcessStatusLog.data?.exitSignal).toBe("SIGTERM")
|
|
236
|
+
if (!handoffServiceStatusLog) throw new Error("expected handoff service cleanup diagnostics to be logged")
|
|
237
|
+
expect(handoffServiceStatusLog.data?.state).toBe("stopped")
|
|
238
|
+
expect(handoffServiceStatusLog.data?.exitSignal).toBe("SIGTERM")
|
|
249
239
|
} finally {
|
|
250
240
|
await daemon.shutdown()
|
|
251
241
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -270,8 +260,8 @@ test("singleton processes restart without overlap during deploy", async () => {
|
|
|
270
260
|
|
|
271
261
|
const status = daemon.status()
|
|
272
262
|
|
|
273
|
-
|
|
274
|
-
|
|
263
|
+
expect(status.singletons.length).toBe(1)
|
|
264
|
+
expect(status.singletons[0].process.state).toBe("running")
|
|
275
265
|
} finally {
|
|
276
266
|
await daemon.shutdown()
|
|
277
267
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -319,9 +309,12 @@ test("candidate activation quiesces the old jobs generation before a blocked sin
|
|
|
319
309
|
await singletonReplacementBlocked
|
|
320
310
|
await Promise.resolve()
|
|
321
311
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
312
|
+
// Candidate traffic must already be active.
|
|
313
|
+
expect(daemon.status().activeReleaseId).toBe("v2")
|
|
314
|
+
// Deploy must remain pending on singleton replacement.
|
|
315
|
+
expect(deploySettled).toBe(false)
|
|
316
|
+
// Old jobs-main must quiesce before singleton replacement completes.
|
|
317
|
+
expect(await fs.readFile(fixture.serviceQuietPath, "utf8")).toBe("v1\n")
|
|
325
318
|
|
|
326
319
|
await fs.writeFile(singletonGatePath, "continue\n")
|
|
327
320
|
singletonGateReleased = true
|
|
@@ -350,7 +343,7 @@ test("a failed singleton replacement surfaces the error after stopping the old s
|
|
|
350
343
|
await waitFor(async () => (await processEvents(fixture.singletonLogPath)).some((event) => event.event === "start" && event.releaseId === "v1"))
|
|
351
344
|
|
|
352
345
|
// The new release's singleton fails to start, so the deploy surfaces the error.
|
|
353
|
-
await
|
|
346
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow()
|
|
354
347
|
|
|
355
348
|
// The old singleton is stopped before the new one is started, so two copies never
|
|
356
349
|
// overlap — even when the replacement then fails.
|
|
@@ -360,9 +353,9 @@ test("a failed singleton replacement surfaces the error after stopping the old s
|
|
|
360
353
|
|
|
361
354
|
// Traffic switches before singletons are replaced, so the new release is already active,
|
|
362
355
|
// but its singleton is left failed with no replacement running.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
356
|
+
expect(status.activeReleaseId).toBe("v2")
|
|
357
|
+
expect(status.singletons.length).toBe(1)
|
|
358
|
+
expect(status.singletons[0].process.state).toBe("failed")
|
|
366
359
|
} finally {
|
|
367
360
|
await daemon.shutdown()
|
|
368
361
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -379,15 +372,15 @@ test("service processes start before releases and restart with the latest deploy
|
|
|
379
372
|
|
|
380
373
|
const firstServiceStatus = daemon.status().services[0].process
|
|
381
374
|
|
|
382
|
-
|
|
383
|
-
|
|
375
|
+
expect(firstServiceStatus.pid).toBeTruthy()
|
|
376
|
+
expect(firstServiceStatus.command).toMatch(/v1/)
|
|
384
377
|
|
|
385
378
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
386
379
|
|
|
387
380
|
const secondServiceStatus = daemon.status().services[0].process
|
|
388
381
|
|
|
389
|
-
|
|
390
|
-
|
|
382
|
+
expect(secondServiceStatus.pid).toBe(firstServiceStatus.pid)
|
|
383
|
+
expect(secondServiceStatus.command).toMatch(/v2/)
|
|
391
384
|
|
|
392
385
|
process.kill(-Number(secondServiceStatus.pid), "SIGTERM")
|
|
393
386
|
await waitFor(async () => {
|
|
@@ -413,28 +406,28 @@ test("handoff services start per release and drain with their release", async ()
|
|
|
413
406
|
const v1 = statusRelease(daemon, "v1")
|
|
414
407
|
const v1Service = v1.processes.find((processStatus) => processStatus.id === "beacon")
|
|
415
408
|
|
|
416
|
-
|
|
417
|
-
|
|
409
|
+
expect(v1Service?.pid).toBeTruthy()
|
|
410
|
+
expect(v1.ports.beacon > 0).toBe(true)
|
|
418
411
|
|
|
419
412
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
420
413
|
const v2 = statusRelease(daemon, "v2")
|
|
421
414
|
const v2Service = v2.processes.find((processStatus) => processStatus.id === "beacon")
|
|
422
415
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
416
|
+
expect(v2Service?.pid).toBeTruthy()
|
|
417
|
+
expect(v2.ports.beacon).not.toBe(v1.ports.beacon)
|
|
418
|
+
expect(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state).toBe("quiesced")
|
|
426
419
|
|
|
427
420
|
socket.close()
|
|
428
421
|
socket = undefined
|
|
429
422
|
|
|
430
423
|
await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
|
|
431
|
-
|
|
424
|
+
expect(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state).toBe("stopped")
|
|
432
425
|
|
|
433
426
|
const events = await processEvents(fixture.serviceLogPath)
|
|
434
427
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
428
|
+
expect(events.some((event) => event.event === "start" && event.releaseId === "v1")).toBe(true)
|
|
429
|
+
expect(events.some((event) => event.event === "start" && event.releaseId === "v2")).toBe(true)
|
|
430
|
+
expect(events.some((event) => event.event === "stop" && event.releaseId === "v1")).toBe(true)
|
|
438
431
|
} finally {
|
|
439
432
|
if (socket) socket.close()
|
|
440
433
|
await daemon.shutdown()
|
|
@@ -457,8 +450,8 @@ test("handoff services stop after release-local dependents finish draining", asy
|
|
|
457
450
|
|
|
458
451
|
const drainingRelease = statusRelease(daemon, "v1")
|
|
459
452
|
|
|
460
|
-
|
|
461
|
-
|
|
453
|
+
expect(drainingRelease.state).toBe("draining")
|
|
454
|
+
expect(drainingRelease.processes.find((processStatus) => processStatus.id === "beacon")?.state).toBe("quiesced")
|
|
462
455
|
|
|
463
456
|
socket.close()
|
|
464
457
|
socket = undefined
|
|
@@ -467,7 +460,8 @@ test("handoff services stop after release-local dependents finish draining", asy
|
|
|
467
460
|
const events = await processEvents(fixture.serviceLogPath)
|
|
468
461
|
const v1ServiceStop = events.find((event) => event.event === "stop" && event.releaseId === "v1")
|
|
469
462
|
|
|
470
|
-
|
|
463
|
+
// V1 handoff service should stop after release drain.
|
|
464
|
+
expect(v1ServiceStop).toBeTruthy()
|
|
471
465
|
} finally {
|
|
472
466
|
if (socket) socket.close()
|
|
473
467
|
await daemon.shutdown()
|
|
@@ -485,20 +479,26 @@ test("candidate activation retires jobs-main with its workers without waiting fo
|
|
|
485
479
|
const oldService = oldRelease.processes.find((processStatus) => processStatus.id === "beacon")
|
|
486
480
|
const oldWorker = oldRelease.processes.find((processStatus) => processStatus.id === "worker")
|
|
487
481
|
|
|
488
|
-
|
|
489
|
-
|
|
482
|
+
expect(oldService?.pid).toBeTruthy()
|
|
483
|
+
expect(oldWorker?.pid).toBeTruthy()
|
|
490
484
|
|
|
491
485
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
492
486
|
|
|
493
|
-
|
|
494
|
-
|
|
487
|
+
// Traffic must switch only after the complete candidate is healthy.
|
|
488
|
+
expect(daemon.status().activeReleaseId).toBe("v2")
|
|
489
|
+
// Old jobs-main must quiesce immediately after candidate activation.
|
|
490
|
+
expect(await fs.readFile(fixture.serviceQuietPath, "utf8")).toBe("v1\n")
|
|
495
491
|
|
|
496
492
|
const retired = statusRelease(daemon, "v1")
|
|
497
493
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
494
|
+
// Deployment completion must not wait for the old jobs generation.
|
|
495
|
+
expect(retired.state).toBe("draining")
|
|
496
|
+
// Old jobs-main must remain alive and quiesced with its draining workers.
|
|
497
|
+
expect(retired.processes.find((processStatus) => processStatus.id === "beacon")?.state).toBe("quiesced")
|
|
498
|
+
// Old worker must remain in its original generation until accepted work settles.
|
|
499
|
+
expect(retired.processes.find((processStatus) => processStatus.id === "worker")?.state).not.toBe("stopped")
|
|
500
|
+
// Old and new workers must retain distinct jobs-main endpoints.
|
|
501
|
+
expect(statusRelease(daemon, "v2").ports.beacon).not.toBe(retired.ports.beacon)
|
|
502
502
|
} finally {
|
|
503
503
|
await daemon.shutdown()
|
|
504
504
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -511,14 +511,14 @@ test("opt-in generation lifecycle acknowledges old retirement before activating
|
|
|
511
511
|
|
|
512
512
|
try {
|
|
513
513
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
514
|
-
|
|
515
|
-
|
|
514
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1"])
|
|
515
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
516
516
|
|
|
517
517
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
518
518
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
519
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "activate:v2"])
|
|
520
|
+
expect(daemon.status().activeReleaseId).toBe("v2")
|
|
521
|
+
expect(daemon.status().generationTransition?.phase).toBe("committed")
|
|
522
522
|
} finally {
|
|
523
523
|
await daemon.shutdown()
|
|
524
524
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -535,11 +535,11 @@ test("manual restart reaches the active handoff coordinator and restores its lif
|
|
|
535
535
|
const result = await daemon.restartProcesses({processId: "beacon"})
|
|
536
536
|
const after = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
|
|
537
537
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
538
|
+
expect(result).toEqual({restarted: ["beacon"]})
|
|
539
|
+
expect(before).toBeTruthy()
|
|
540
|
+
expect(after).toBeTruthy()
|
|
541
|
+
expect(after).not.toBe(before)
|
|
542
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "activate:v1"])
|
|
543
543
|
} finally {
|
|
544
544
|
await daemon.shutdown()
|
|
545
545
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -574,14 +574,14 @@ test("generation commit is durable before awaited post-transition work", async (
|
|
|
574
574
|
|
|
575
575
|
const persisted = /** @type {{activeReleaseId?: string, generationTransition?: {phase?: string}, singletonReleaseIds?: Record<string, string>} | undefined} */ (await readState(fixture.statePath))
|
|
576
576
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
577
|
+
expect(daemon.status().activeReleaseId).toBe("v2")
|
|
578
|
+
expect(persisted?.activeReleaseId).toBe("v2")
|
|
579
|
+
expect(persisted?.generationTransition?.phase).toBe("committed_pending")
|
|
580
|
+
expect(persisted?.singletonReleaseIds?.["jobs-main"]).toBe("v1")
|
|
581
581
|
|
|
582
582
|
releaseReplacement()
|
|
583
583
|
await deployPromise
|
|
584
|
-
|
|
584
|
+
expect(daemon.status().generationTransition?.phase).toBe("committed")
|
|
585
585
|
} finally {
|
|
586
586
|
releaseReplacement()
|
|
587
587
|
await deployPromise?.catch(() => {})
|
|
@@ -598,18 +598,19 @@ test("exact committed retry finishes pending singleton replacement before succes
|
|
|
598
598
|
|
|
599
599
|
try {
|
|
600
600
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
601
|
-
await
|
|
601
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/ENOENT/)
|
|
602
602
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
603
|
+
// Traffic remains durably committed.
|
|
604
|
+
expect(daemon.status().activeReleaseId).toBe("v2")
|
|
605
|
+
expect(daemon.status().generationTransition?.phase).toBe("committed_pending")
|
|
606
|
+
expect(daemon.status().singletons[0]?.process.state).not.toBe("running")
|
|
606
607
|
|
|
607
608
|
await fs.mkdir(path.join(fixture.root, "v2"))
|
|
608
609
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
609
610
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
611
|
+
expect(daemon.status().generationTransition?.phase).toBe("committed")
|
|
612
|
+
expect(daemon.status().singletons[0]?.process.state).toBe("running")
|
|
613
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "activate:v2"])
|
|
613
614
|
} finally {
|
|
614
615
|
await daemon.shutdown()
|
|
615
616
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -621,12 +622,12 @@ test("first generation is not committed when its activation acknowledgement fail
|
|
|
621
622
|
const daemon = await startDaemon(fixture.config)
|
|
622
623
|
|
|
623
624
|
try {
|
|
624
|
-
await
|
|
625
|
+
await expect(daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})).rejects.toThrow(/activate command exited non-zero/)
|
|
625
626
|
const status = daemon.status()
|
|
626
627
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
628
|
+
expect(status.activeReleaseId).toBe(null)
|
|
629
|
+
expect(status.generationTransition?.phase).toBe("activating_candidate")
|
|
630
|
+
expect(status.releaseReferences.map((reference) => reference.releaseId)).toEqual(["v1"])
|
|
630
631
|
} finally {
|
|
631
632
|
await daemon.shutdown()
|
|
632
633
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -639,21 +640,21 @@ test("retirement acknowledgement failure retains the exact transition, blocks ot
|
|
|
639
640
|
|
|
640
641
|
try {
|
|
641
642
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
642
|
-
await
|
|
643
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/retirement quiescence failed/)
|
|
643
644
|
|
|
644
645
|
const failed = daemon.status()
|
|
645
646
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
await
|
|
647
|
+
expect(failed.activeReleaseId).toBe("v1")
|
|
648
|
+
expect(failed.generationTransition?.phase).toBe("retiring_previous")
|
|
649
|
+
expect(String(failed.generationTransition?.error)).toMatch(/quiet command exited non-zero/)
|
|
650
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1"])
|
|
651
|
+
await expect(daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})).rejects.toThrow(/transition.*v2.*unresolved/i)
|
|
651
652
|
|
|
652
653
|
await fs.writeFile(fixture.retirementGatePath, "allow\n")
|
|
653
654
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
654
655
|
|
|
655
|
-
|
|
656
|
-
|
|
656
|
+
expect(daemon.status().activeReleaseId).toBe("v2")
|
|
657
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "activate:v2"])
|
|
657
658
|
} finally {
|
|
658
659
|
await daemon.shutdown()
|
|
659
660
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -669,41 +670,37 @@ test("candidate activation failure reports restoration failure and exact recover
|
|
|
669
670
|
const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
|
|
670
671
|
const reactivate = incumbentCoordinator?.reactivateStrict.bind(incumbentCoordinator)
|
|
671
672
|
|
|
672
|
-
|
|
673
|
+
if (!(incumbentCoordinator && reactivate)) throw new Error("Missing required fixture: incumbentCoordinator && reactivate")
|
|
673
674
|
incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
assert.match(error.message, /incumbent v1 restoration failed: incumbent restoration rejected/i)
|
|
680
|
-
return true
|
|
681
|
-
}
|
|
682
|
-
)
|
|
675
|
+
const deployment = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
676
|
+
|
|
677
|
+
await expect(deployment).rejects.toBeInstanceOf(AggregateError)
|
|
678
|
+
await expect(deployment).rejects.toMatchObject({message: expect.stringMatching(/activate command exited non-zero/)})
|
|
679
|
+
await expect(deployment).rejects.toMatchObject({message: expect.stringMatching(/incumbent v1 restoration failed: incumbent restoration rejected/i)})
|
|
683
680
|
|
|
684
681
|
const failed = daemon.status()
|
|
685
682
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
683
|
+
expect(failed.activeReleaseId).toBe("v1")
|
|
684
|
+
expect(failed.generationTransition?.phase).toBe("restoring_previous")
|
|
685
|
+
expect(String(failed.generationTransition?.activationError)).toMatch(/activate command exited non-zero/)
|
|
686
|
+
expect(String(failed.generationTransition?.compensationError)).toMatch(/incumbent restoration rejected/)
|
|
690
687
|
const failedEvents = daemon.eventLog.recent()
|
|
691
688
|
const activationEvent = failedEvents.find((event) => event.message === "release generation activation failed")
|
|
692
689
|
const restorationEvent = failedEvents.find((event) => event.message === "release generation compensation restoration failed")
|
|
693
690
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
await
|
|
691
|
+
expect(String(activationEvent?.data.error)).toMatch(/activate command exited non-zero/)
|
|
692
|
+
expect(String(restorationEvent?.data.activationError)).toMatch(/activate command exited non-zero/)
|
|
693
|
+
expect(String(restorationEvent?.data.error)).toMatch(/incumbent restoration rejected/)
|
|
694
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "retire:v2"])
|
|
695
|
+
await expect(daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})).rejects.toThrow(/transition.*v2.*unresolved/i)
|
|
699
696
|
const failedCandidate = daemon.releases.get("v2")
|
|
700
697
|
|
|
701
|
-
|
|
698
|
+
if (!failedCandidate) throw new Error("Missing required fixture: failedCandidate")
|
|
702
699
|
await failedCandidate.stop()
|
|
703
700
|
incumbentCoordinator.reactivateStrict = reactivate
|
|
704
701
|
await incumbentCoordinator.stop()
|
|
705
|
-
|
|
706
|
-
|
|
702
|
+
expect(failedCandidate.state).toBe("stopped")
|
|
703
|
+
expect(incumbentCoordinator.status().state).toBe("stopped")
|
|
707
704
|
daemon.config = structuredClone(daemon.config)
|
|
708
705
|
daemon.config.processes[0].lifecycle.activateTimeoutMs = (daemon.config.processes[0].lifecycle.activateTimeoutMs ?? 30000) + 1
|
|
709
706
|
const recovery = await sendControlCommand({
|
|
@@ -717,13 +714,13 @@ test("candidate activation failure reports restoration failure and exact recover
|
|
|
717
714
|
path: fixture.config.control.path
|
|
718
715
|
})
|
|
719
716
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
717
|
+
expect(recovery.recoveryStatus).toBe("recovered")
|
|
718
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
719
|
+
expect(daemon.status().generationTransition).toBe(undefined)
|
|
723
720
|
const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
|
|
724
721
|
|
|
725
|
-
|
|
726
|
-
|
|
722
|
+
expect(persisted?.generationTransition).toBe(undefined)
|
|
723
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
727
724
|
|
|
728
725
|
const idempotent = await sendControlCommand({
|
|
729
726
|
command: {
|
|
@@ -736,10 +733,9 @@ test("candidate activation failure reports restoration failure and exact recover
|
|
|
736
733
|
path: fixture.config.control.path
|
|
737
734
|
})
|
|
738
735
|
|
|
739
|
-
|
|
736
|
+
expect(idempotent.recoveryStatus).toBe("already_recovered")
|
|
740
737
|
await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
|
|
741
|
-
await
|
|
742
|
-
() => sendControlCommand({
|
|
738
|
+
await expect(sendControlCommand({
|
|
743
739
|
command: {
|
|
744
740
|
command: "recover-generation-transition",
|
|
745
741
|
previousReleaseId: "v1",
|
|
@@ -748,9 +744,7 @@ test("candidate activation failure reports restoration failure and exact recover
|
|
|
748
744
|
revision: "v3"
|
|
749
745
|
},
|
|
750
746
|
path: fixture.config.control.path
|
|
751
|
-
})
|
|
752
|
-
/not a safe failed pre-commit transition/i
|
|
753
|
-
)
|
|
747
|
+
})).rejects.toThrow(/not a safe failed pre-commit transition/i)
|
|
754
748
|
} finally {
|
|
755
749
|
await daemon.shutdown()
|
|
756
750
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -765,12 +759,9 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
|
|
|
765
759
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
766
760
|
const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
|
|
767
761
|
|
|
768
|
-
|
|
762
|
+
if (!incumbentCoordinator) throw new Error("Missing required fixture: incumbentCoordinator")
|
|
769
763
|
incumbentCoordinator.reactivateStrict = async () => { throw new Error("Cannot activate background jobs generation from retired") }
|
|
770
|
-
await
|
|
771
|
-
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
772
|
-
/Cannot activate background jobs generation from retired/i
|
|
773
|
-
)
|
|
764
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/Cannot activate background jobs generation from retired/i)
|
|
774
765
|
|
|
775
766
|
const candidate = daemon.releases.get("v2")
|
|
776
767
|
const transition = daemon.generationTransition
|
|
@@ -788,46 +779,49 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
|
|
|
788
779
|
path: fixture.config.control.path
|
|
789
780
|
})
|
|
790
781
|
|
|
791
|
-
|
|
792
|
-
|
|
782
|
+
if (!(candidate && transition && incumbentWebPid)) throw new Error("Missing required fixture: candidate && transition && incumbentWebPid")
|
|
783
|
+
// Ordinary failed compensation leaves the candidate draining.
|
|
784
|
+
expect(candidate.state).toBe("draining")
|
|
793
785
|
|
|
794
786
|
const retainedCandidateConfig = candidate.config
|
|
795
787
|
|
|
796
788
|
candidate.config = {...candidate.config, releaseRetention: {...candidate.config.releaseRetention, keep: candidate.config.releaseRetention.keep + 1}}
|
|
797
|
-
await
|
|
789
|
+
await expect(exactRecovery()).rejects.toThrow(/does not retain its exact path, revision, and config authority/i)
|
|
798
790
|
candidate.config = retainedCandidateConfig
|
|
799
|
-
await
|
|
800
|
-
await
|
|
791
|
+
await expect(exactRecovery({previousReleaseId: "wrong-v1"})).rejects.toThrow(/refusing stale recovery/i)
|
|
792
|
+
await expect(exactRecovery({revision: "wrong-v2"})).rejects.toThrow(/exact same release, path, revision, and config authority/i)
|
|
801
793
|
transition.phase = "retiring_failed_candidate"
|
|
802
|
-
await
|
|
794
|
+
await expect(exactRecovery()).rejects.toThrow(/requires retiring_previous or restoring_previous/i)
|
|
803
795
|
transition.phase = "restoring_previous"
|
|
804
796
|
const terminalFailure = transition.compensationError
|
|
805
797
|
|
|
806
798
|
transition.compensationError = "incumbent activation was temporarily unavailable"
|
|
807
|
-
await
|
|
799
|
+
await expect(exactRecovery()).rejects.toThrow(/terminal retirement/i)
|
|
808
800
|
transition.compensationError = terminalFailure
|
|
809
801
|
await incumbentCoordinator.setLifecycleRole("retired")
|
|
810
|
-
|
|
802
|
+
expect(incumbentCoordinator.status().lifecycleRole).toBe("retired")
|
|
811
803
|
const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
|
|
812
804
|
|
|
813
805
|
daemon.checkpointGenerationTransition = async () => { throw new Error("injected checkpoint failure") }
|
|
814
|
-
await
|
|
815
|
-
|
|
806
|
+
await expect(exactRecovery()).rejects.toThrow(/checkpoint failed: injected checkpoint failure/i)
|
|
807
|
+
expect(daemon.generationTransition).toBe(transition)
|
|
816
808
|
daemon.checkpointGenerationTransition = checkpoint
|
|
817
809
|
|
|
818
810
|
const eventsBeforeRecovery = await lifecycleEvents(fixture.lifecycleLogPath)
|
|
819
811
|
const recovery = await exactRecovery()
|
|
820
812
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
813
|
+
expect(recovery.recoveryStatus).toBe("retired_incumbent_accepted")
|
|
814
|
+
expect(recovery.jobsStatus).toBe("degraded")
|
|
815
|
+
expect(daemon.status().generationTransition?.phase).toBe("degraded_active")
|
|
816
|
+
expect(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid).toBe(incumbentWebPid)
|
|
817
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
818
|
+
// Guarded recovery returns before failed-candidate drain completion.
|
|
819
|
+
expect(["draining", "stopped"].includes(candidate.state)).toBe(true)
|
|
820
|
+
// Recovery must not activate either retained generation.
|
|
821
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(eventsBeforeRecovery)
|
|
828
822
|
const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
|
|
829
823
|
|
|
830
|
-
|
|
824
|
+
expect(/** @type {{phase?: string} | undefined} */ (persisted?.generationTransition)?.phase).toBe("degraded_active")
|
|
831
825
|
|
|
832
826
|
transition.phase = "retiring_previous"
|
|
833
827
|
transition.error = "Release v1 retirement quiescence failed: quiet command exited non-zero with status 1"
|
|
@@ -836,8 +830,9 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
|
|
|
836
830
|
await daemon.checkpointGenerationTransition()
|
|
837
831
|
const legacyRecovery = await exactRecovery()
|
|
838
832
|
|
|
839
|
-
|
|
840
|
-
|
|
833
|
+
expect(legacyRecovery.recoveryStatus).toBe("retired_incumbent_accepted")
|
|
834
|
+
// Guarded recovery migrates a legacy terminal retirement fence.
|
|
835
|
+
expect(daemon.status().generationTransition?.phase).toBe("degraded_active")
|
|
841
836
|
|
|
842
837
|
transition.phase = "restoring_previous"
|
|
843
838
|
transition.compensationError = "Process background-jobs-main is not retained for reactivation"
|
|
@@ -845,17 +840,19 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
|
|
|
845
840
|
await daemon.checkpointGenerationTransition()
|
|
846
841
|
const absentCoordinatorRecovery = await exactRecovery()
|
|
847
842
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
843
|
+
expect(absentCoordinatorRecovery.jobsStatus).toBe("degraded")
|
|
844
|
+
// Terminally absent incumbent coordinator remains guarded jobs-degraded authority.
|
|
845
|
+
expect(daemon.status().generationTransition?.phase).toBe("degraded_active")
|
|
846
|
+
await expect(daemon.deploy({releaseId: "bad-v3", releasePath: fixture.root, revision: "bad-v3"})).rejects.toThrow(/health check failed/i)
|
|
847
|
+
expect(daemon.status().generationTransition?.phase).toBe("degraded_active")
|
|
848
|
+
expect(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid).toBe(incumbentWebPid)
|
|
849
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
854
850
|
await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
851
|
+
expect(daemon.status().activeReleaseId).toBe("v3")
|
|
852
|
+
expect(daemon.status().generationTransition?.phase).toBe("committed")
|
|
853
|
+
expect(await fetchText(daemon, "/release")).toBe("v3")
|
|
854
|
+
// Fresh deployment must not re-retire a degraded incumbent generation.
|
|
855
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "retire:v2", "retire:bad-v3", "activate:v3"])
|
|
859
856
|
} finally {
|
|
860
857
|
await daemon.shutdown()
|
|
861
858
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -868,23 +865,20 @@ test("candidate activation failure compensates to the incumbent and admits a dif
|
|
|
868
865
|
|
|
869
866
|
try {
|
|
870
867
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
871
|
-
await
|
|
872
|
-
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
873
|
-
/activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
|
|
874
|
-
)
|
|
868
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i)
|
|
875
869
|
|
|
876
870
|
const compensated = daemon.status()
|
|
877
871
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
872
|
+
expect(compensated.activeReleaseId).toBe("v1")
|
|
873
|
+
expect(compensated.generationTransition).toBe(undefined)
|
|
874
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
875
|
+
expect(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state).toBe("running")
|
|
876
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
883
877
|
|
|
884
878
|
await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
|
|
885
879
|
|
|
886
|
-
|
|
887
|
-
|
|
880
|
+
expect(daemon.status().activeReleaseId).toBe("v3")
|
|
881
|
+
expect(await fetchText(daemon, "/release")).toBe("v3")
|
|
888
882
|
} finally {
|
|
889
883
|
await daemon.shutdown()
|
|
890
884
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -897,20 +891,17 @@ test("ambiguous candidate activation retires the candidate before reactivating t
|
|
|
897
891
|
|
|
898
892
|
try {
|
|
899
893
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
900
|
-
await
|
|
901
|
-
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
902
|
-
/activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
|
|
903
|
-
)
|
|
894
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i)
|
|
904
895
|
|
|
905
|
-
|
|
896
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual([
|
|
906
897
|
"activate:v1",
|
|
907
898
|
"retire:v1",
|
|
908
899
|
"activate:v2",
|
|
909
900
|
"retire:v2",
|
|
910
901
|
"activate:v1"
|
|
911
902
|
])
|
|
912
|
-
|
|
913
|
-
|
|
903
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
904
|
+
expect(daemon.status().generationTransition).toBe(undefined)
|
|
914
905
|
} finally {
|
|
915
906
|
await daemon.shutdown()
|
|
916
907
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -923,20 +914,17 @@ test("candidate activation recovery reverses a worker-specific quiet hook before
|
|
|
923
914
|
|
|
924
915
|
try {
|
|
925
916
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
926
|
-
await
|
|
927
|
-
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
928
|
-
/compensation restored incumbent v1 as authoritative/i
|
|
929
|
-
)
|
|
917
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/compensation restored incumbent v1 as authoritative/i)
|
|
930
918
|
|
|
931
919
|
const events = await lifecycleEvents(fixture.lifecycleLogPath)
|
|
932
920
|
const candidateRetired = events.indexOf("worker-retire:v2")
|
|
933
921
|
const workerReactivated = events.indexOf("worker-reactivate:v1")
|
|
934
922
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
923
|
+
expect({value: Boolean(candidateRetired >= 0), context: JSON.stringify(events)}).toMatchObject({value: true})
|
|
924
|
+
expect({value: Boolean(workerReactivated > candidateRetired), context: JSON.stringify(events)}).toMatchObject({value: true})
|
|
925
|
+
expect(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state).toBe("running")
|
|
926
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
927
|
+
expect(daemon.status().generationTransition).toBe(undefined)
|
|
940
928
|
} finally {
|
|
941
929
|
await daemon.shutdown()
|
|
942
930
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -949,28 +937,22 @@ test("candidate activation recovery keeps the fence when a worker-specific resum
|
|
|
949
937
|
|
|
950
938
|
try {
|
|
951
939
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
assert.match(failure.message, /activate command exited non-zero/)
|
|
958
|
-
assert.match(failure.message, /reactivate command exited non-zero/)
|
|
959
|
-
return true
|
|
960
|
-
}
|
|
961
|
-
)
|
|
940
|
+
const deployment = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
941
|
+
|
|
942
|
+
await expect(deployment).rejects.toMatchObject({message: expect.stringMatching(/activate command exited non-zero/)})
|
|
943
|
+
await expect(deployment).rejects.toMatchObject({message: expect.stringMatching(/reactivate command exited non-zero/)})
|
|
962
944
|
|
|
963
945
|
const status = daemon.status()
|
|
964
946
|
const restorationEvent = daemon.eventLog.recent().find((event) => event.message === "release generation compensation restoration failed")
|
|
965
947
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
await
|
|
948
|
+
expect(status.activeReleaseId).toBe("v1")
|
|
949
|
+
expect(status.generationTransition?.phase).toBe("restoring_previous")
|
|
950
|
+
expect(String(status.generationTransition?.activationError)).toMatch(/activate command exited non-zero/)
|
|
951
|
+
expect(String(status.generationTransition?.compensationError)).toMatch(/reactivate command exited non-zero/)
|
|
952
|
+
expect(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state).toBe("quiesced")
|
|
953
|
+
expect(String(restorationEvent?.data.activationError)).toMatch(/activate command exited non-zero/)
|
|
954
|
+
expect(String(restorationEvent?.data.error)).toMatch(/reactivate command exited non-zero/)
|
|
955
|
+
await expect(daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})).rejects.toThrow(/transition.*v2.*unresolved/i)
|
|
974
956
|
} finally {
|
|
975
957
|
await daemon.shutdown()
|
|
976
958
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -989,17 +971,14 @@ test("compensation keeps the fence when the cleared checkpoint cannot be persist
|
|
|
989
971
|
if (!daemon.generationTransition) throw new Error("cleared checkpoint unavailable")
|
|
990
972
|
await checkpoint()
|
|
991
973
|
}
|
|
992
|
-
await
|
|
993
|
-
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
994
|
-
/activate command exited non-zero.*compensation checkpoint clear failed: cleared checkpoint unavailable/i
|
|
995
|
-
)
|
|
974
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/activate command exited non-zero.*compensation checkpoint clear failed: cleared checkpoint unavailable/i)
|
|
996
975
|
|
|
997
|
-
|
|
998
|
-
|
|
976
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
977
|
+
expect(daemon.status().generationTransition?.phase).toBe("restoring_previous")
|
|
999
978
|
const persisted = /** @type {{generationTransition?: {phase?: string}} | undefined} */ (await readState(fixture.statePath))
|
|
1000
979
|
|
|
1001
|
-
|
|
1002
|
-
await
|
|
980
|
+
expect(persisted?.generationTransition?.phase).toBe("restoring_previous")
|
|
981
|
+
await expect(daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})).rejects.toThrow(/transition.*v2.*unresolved/i)
|
|
1003
982
|
|
|
1004
983
|
daemon.checkpointGenerationTransition = checkpoint
|
|
1005
984
|
const recovery = await sendControlCommand({
|
|
@@ -1013,8 +992,8 @@ test("compensation keeps the fence when the cleared checkpoint cannot be persist
|
|
|
1013
992
|
path: fixture.config.control.path
|
|
1014
993
|
})
|
|
1015
994
|
|
|
1016
|
-
|
|
1017
|
-
|
|
995
|
+
expect(recovery.recoveryStatus).toBe("recovered")
|
|
996
|
+
expect(daemon.status().generationTransition).toBe(undefined)
|
|
1018
997
|
} finally {
|
|
1019
998
|
await daemon.shutdown()
|
|
1020
999
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1029,16 +1008,16 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
|
|
|
1029
1008
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1030
1009
|
const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
|
|
1031
1010
|
|
|
1032
|
-
|
|
1011
|
+
if (!incumbentCoordinator) throw new Error("Missing required fixture: incumbentCoordinator")
|
|
1033
1012
|
incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
|
|
1034
|
-
await
|
|
1013
|
+
await expect(daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})).rejects.toThrow(/activate command exited non-zero/)
|
|
1035
1014
|
|
|
1036
|
-
await
|
|
1037
|
-
await
|
|
1038
|
-
await
|
|
1015
|
+
await expect(daemon.stopRelease("v2")).rejects.toThrow(/cannot stop.*generation transition.*unresolved/i)
|
|
1016
|
+
await expect(daemon.restartProcesses({processId: "beacon"})).rejects.toThrow(/cannot restart.*generation transition.*unresolved/i)
|
|
1017
|
+
await expect(daemon.rollback({releaseId: "v2"})).rejects.toThrow(/cannot rollback.*generation transition.*unresolved/i)
|
|
1039
1018
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1019
|
+
expect(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state).not.toBe("stopped")
|
|
1020
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
1042
1021
|
} finally {
|
|
1043
1022
|
await daemon.shutdown()
|
|
1044
1023
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1053,13 +1032,13 @@ test("active generation restores its exact lifecycle role after coordinator auto
|
|
|
1053
1032
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1054
1033
|
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
1055
1034
|
|
|
1056
|
-
|
|
1035
|
+
if (!coordinator?.pid) throw new Error("Missing required fixture: coordinator?.pid")
|
|
1057
1036
|
process.kill(-coordinator.pid, "SIGKILL")
|
|
1058
1037
|
await waitFor(async () => (await lifecycleEvents(fixture.lifecycleLogPath)).length === 2 && statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state === "running", 3000)
|
|
1059
1038
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1039
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "activate:v1"])
|
|
1040
|
+
expect(statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state).toBe("running")
|
|
1041
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
1063
1042
|
} finally {
|
|
1064
1043
|
await daemon.shutdown()
|
|
1065
1044
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1077,7 +1056,7 @@ test("failed active-role restoration is loud and never reports the restarted coo
|
|
|
1077
1056
|
await fs.rm(fixture.activationGatePath)
|
|
1078
1057
|
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
1079
1058
|
|
|
1080
|
-
|
|
1059
|
+
if (!coordinator?.pid) throw new Error("Missing required fixture: coordinator?.pid")
|
|
1081
1060
|
process.kill(-coordinator.pid, "SIGKILL")
|
|
1082
1061
|
await waitFor(() => {
|
|
1083
1062
|
const status = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
@@ -1087,9 +1066,10 @@ test("failed active-role restoration is loud and never reports the restarted coo
|
|
|
1087
1066
|
|
|
1088
1067
|
const failed = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
1089
1068
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1069
|
+
expect(failed?.lifecycleRole).toBe("active")
|
|
1070
|
+
// Role restoration failure must not create an internal retry loop.
|
|
1071
|
+
expect(failed?.restarts).toBe(1)
|
|
1072
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1"])
|
|
1093
1073
|
} finally {
|
|
1094
1074
|
await daemon.shutdown()
|
|
1095
1075
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1110,25 +1090,27 @@ test("retired generation coordinator remains fenced after exit", async () => {
|
|
|
1110
1090
|
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
1111
1091
|
const coordinatorProcess = daemon.releases.get("v1")?.getProcess("beacon")
|
|
1112
1092
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1093
|
+
if (!coordinator?.pid) throw new Error("Missing required fixture: coordinator?.pid")
|
|
1094
|
+
if (!coordinatorProcess) throw new Error("Missing required fixture: coordinatorProcess")
|
|
1095
|
+
expect(coordinator.lifecycleRole).toBe("retired")
|
|
1096
|
+
// Retirement refresh must preserve exact guardian event routing.
|
|
1097
|
+
expect(daemon.guardian?.processes.get("release:v1:beacon")).toBe(coordinatorProcess)
|
|
1117
1098
|
const exited = once(coordinatorProcess, "exit")
|
|
1118
1099
|
|
|
1119
1100
|
process.kill(-coordinator.pid, "SIGKILL")
|
|
1120
1101
|
const [exit] = await exited
|
|
1121
1102
|
const stopped = coordinatorProcess.status()
|
|
1122
1103
|
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1104
|
+
expect(exit.code).toBe(null)
|
|
1105
|
+
expect(exit.id).toBe("beacon")
|
|
1106
|
+
expect(exit.signal).toBe("SIGKILL")
|
|
1107
|
+
expect(await lifecycleEvents(fixture.lifecycleLogPath)).toEqual(["activate:v1", "retire:v1", "activate:v2"])
|
|
1108
|
+
expect(stopped.lifecycleRole).toBe("retired")
|
|
1109
|
+
expect(stopped.pid).toBe(undefined)
|
|
1110
|
+
expect(stopped.restarts).toBe(0)
|
|
1111
|
+
expect(stopped.state).toBe("stopped")
|
|
1112
|
+
// Retired process must not queue a restart.
|
|
1113
|
+
expect(coordinatorProcess.restartTimer).toBe(undefined)
|
|
1132
1114
|
} finally {
|
|
1133
1115
|
socket?.close()
|
|
1134
1116
|
await daemon.shutdown()
|
|
@@ -1153,14 +1135,14 @@ test("multiple retired jobs generations keep distinct endpoints and live referen
|
|
|
1153
1135
|
const status = daemon.status()
|
|
1154
1136
|
const generations = ["v1", "v2", "v3"].map((releaseId) => statusRelease(daemon, releaseId))
|
|
1155
1137
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1138
|
+
expect(generations.map((release) => release.state)).toEqual(["draining", "draining", "active"])
|
|
1139
|
+
expect(new Set(generations.map((release) => release.ports.beacon)).size).toBe(3)
|
|
1140
|
+
expect(status.releaseReferences.map((reference) => reference.releaseId)).toEqual(["v1", "v2", "v3"])
|
|
1141
|
+
expect(status.releaseReferences.map((reference) => reference.releasePath)).toEqual(["v1", "v2", "v3"].map((releaseId) => path.join(fixture.root, releaseId)))
|
|
1160
1142
|
|
|
1161
1143
|
for (const socket of sockets.splice(0)) socket.close()
|
|
1162
1144
|
await waitFor(() => statusRelease(daemon, "v1").state === "stopped" && statusRelease(daemon, "v2").state === "stopped")
|
|
1163
|
-
|
|
1145
|
+
expect(daemon.status().releaseReferences.map((reference) => reference.releaseId)).toEqual(["v3"])
|
|
1164
1146
|
} finally {
|
|
1165
1147
|
for (const socket of sockets) socket.close()
|
|
1166
1148
|
await daemon.shutdown()
|
|
@@ -1177,16 +1159,17 @@ test("candidate failure preserves old traffic, jobs generation, endpoint, and re
|
|
|
1177
1159
|
const before = statusRelease(daemon, "good")
|
|
1178
1160
|
const beforeService = before.processes.find((processStatus) => processStatus.id === "beacon")
|
|
1179
1161
|
|
|
1180
|
-
await
|
|
1162
|
+
await expect(daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"})).rejects.toThrow(/Health check failed/)
|
|
1181
1163
|
|
|
1182
1164
|
const after = statusRelease(daemon, "good")
|
|
1183
1165
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1166
|
+
expect(await fetchText(daemon, "/release")).toBe("good")
|
|
1167
|
+
expect(after.state).toBe("active")
|
|
1168
|
+
expect(after.ports.beacon).toBe(before.ports.beacon)
|
|
1169
|
+
expect(after.processes.find((processStatus) => processStatus.id === "beacon")?.pid).toBe(beforeService?.pid)
|
|
1170
|
+
// Candidate cleanup must not quiesce the active generation.
|
|
1171
|
+
expect((await fs.readFile(fixture.serviceQuietPath, "utf8")).includes("good\n")).toBe(false)
|
|
1172
|
+
expect(daemon.status().releaseReferences.map((reference) => reference.releaseId)).toEqual(["good"])
|
|
1190
1173
|
} finally {
|
|
1191
1174
|
await daemon.shutdown()
|
|
1192
1175
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1206,12 +1189,12 @@ test("handoff-service quiescence failure is visible and leaves the generation al
|
|
|
1206
1189
|
|
|
1207
1190
|
const retired = statusRelease(daemon, "v1")
|
|
1208
1191
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1192
|
+
expect(retired.state).toBe("draining")
|
|
1193
|
+
expect(String(retired.retirementError)).toMatch(/quiet command exited non-zero.*23/)
|
|
1194
|
+
expect(retired.processes.find((processStatus) => processStatus.id === "beacon")?.state).toBe("stopping")
|
|
1195
|
+
expect(retired.processes.find((processStatus) => processStatus.id === "worker")?.state).not.toBe("stopped")
|
|
1196
|
+
expect(logs.some((entry) => entry.message === "release retirement quiescence failed" && entry.data?.releaseId === "v1")).toBeTruthy()
|
|
1197
|
+
expect(result.retirement).toEqual({error: retired.retirementError, releaseId: "v1", status: "quiescence_failed"})
|
|
1215
1198
|
} finally {
|
|
1216
1199
|
await daemon.shutdown()
|
|
1217
1200
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1227,21 +1210,21 @@ test("a replicated companion starts one instance per replica, and restart target
|
|
|
1227
1210
|
|
|
1228
1211
|
const release = daemon.status().releases.find((candidate) => candidate.state === "active")
|
|
1229
1212
|
|
|
1230
|
-
|
|
1213
|
+
if (!release) throw new Error("Missing required fixture: release")
|
|
1231
1214
|
|
|
1232
1215
|
const workerIds = release.processes.filter((processStatus) => processStatus.id.startsWith("worker")).map((processStatus) => processStatus.id).sort()
|
|
1233
1216
|
|
|
1234
|
-
|
|
1217
|
+
expect(workerIds).toEqual(["worker#0", "worker#1", "worker#2"])
|
|
1235
1218
|
|
|
1236
1219
|
// A specific replica id restarts only that replica.
|
|
1237
1220
|
const one = await daemon.restartProcesses({processId: "worker#1"})
|
|
1238
1221
|
|
|
1239
|
-
|
|
1222
|
+
expect(one.restarted).toEqual(["worker#1"])
|
|
1240
1223
|
|
|
1241
1224
|
// The base id restarts every replica.
|
|
1242
1225
|
const all = /** @type {string[]} */ ((await daemon.restartProcesses({processId: "worker"})).restarted)
|
|
1243
1226
|
|
|
1244
|
-
|
|
1227
|
+
expect([...all].sort()).toEqual(["worker#0", "worker#1", "worker#2"])
|
|
1245
1228
|
} finally {
|
|
1246
1229
|
await daemon.shutdown()
|
|
1247
1230
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1258,12 +1241,12 @@ test("restart bounces a single process by id", async () => {
|
|
|
1258
1241
|
const before = pidsById(daemon.status())
|
|
1259
1242
|
const result = await daemon.restartProcesses({processId: "beacon"})
|
|
1260
1243
|
|
|
1261
|
-
|
|
1244
|
+
expect(result.restarted).toEqual(["beacon"])
|
|
1262
1245
|
|
|
1263
1246
|
const after = pidsById(daemon.status())
|
|
1264
1247
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1248
|
+
expect(before.beacon && after.beacon).toBeTruthy()
|
|
1249
|
+
expect(after.beacon).not.toBe(before.beacon)
|
|
1267
1250
|
} finally {
|
|
1268
1251
|
await daemon.shutdown()
|
|
1269
1252
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1281,14 +1264,15 @@ test("restart with no selector bounces every non-proxied process but not the pro
|
|
|
1281
1264
|
const result = await daemon.restartProcesses()
|
|
1282
1265
|
const restarted = /** @type {string[]} */ (result.restarted)
|
|
1283
1266
|
|
|
1284
|
-
|
|
1267
|
+
expect([...restarted].sort()).toEqual(["beacon", "jobs-main", "worker"])
|
|
1285
1268
|
|
|
1286
1269
|
const after = pidsById(daemon.status())
|
|
1287
1270
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1271
|
+
// Proxied process should not be restarted.
|
|
1272
|
+
expect(after.web).toBe(before.web)
|
|
1273
|
+
expect(after.beacon).not.toBe(before.beacon)
|
|
1274
|
+
expect(after["jobs-main"]).not.toBe(before["jobs-main"])
|
|
1275
|
+
expect(after.worker).not.toBe(before.worker)
|
|
1292
1276
|
} finally {
|
|
1293
1277
|
await daemon.shutdown()
|
|
1294
1278
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1305,12 +1289,13 @@ test("restart --policy targets only processes with that policy", async () => {
|
|
|
1305
1289
|
const before = pidsById(daemon.status())
|
|
1306
1290
|
const result = await daemon.restartProcesses({policy: "companion"})
|
|
1307
1291
|
|
|
1308
|
-
|
|
1292
|
+
expect(result.restarted).toEqual(["worker"])
|
|
1309
1293
|
|
|
1310
1294
|
const after = pidsById(daemon.status())
|
|
1311
1295
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1296
|
+
expect(after.worker).not.toBe(before.worker)
|
|
1297
|
+
// The service should be left running.
|
|
1298
|
+
expect(after.beacon).toBe(before.beacon)
|
|
1314
1299
|
} finally {
|
|
1315
1300
|
await daemon.shutdown()
|
|
1316
1301
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1324,9 +1309,9 @@ test("restart refuses the proxied process and reports unknown ids", async () =>
|
|
|
1324
1309
|
try {
|
|
1325
1310
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1326
1311
|
|
|
1327
|
-
await
|
|
1328
|
-
await
|
|
1329
|
-
await
|
|
1312
|
+
await expect(daemon.restartProcesses({processId: "web"})).rejects.toThrow(/proxied process cannot be restarted/)
|
|
1313
|
+
await expect(daemon.restartProcesses({policy: "proxied"})).rejects.toThrow(/proxied process cannot be restarted/)
|
|
1314
|
+
await expect(daemon.restartProcesses({processId: "missing"})).rejects.toThrow(/No managed process with id "missing"/)
|
|
1330
1315
|
} finally {
|
|
1331
1316
|
await daemon.shutdown()
|
|
1332
1317
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1343,15 +1328,15 @@ test("restart revives a stopped process instead of erroring", async () => {
|
|
|
1343
1328
|
// Simulate the worker having exited (e.g. crashed and exhausted its restart budget).
|
|
1344
1329
|
const worker = daemon.activeRelease?.getProcess("worker")
|
|
1345
1330
|
|
|
1346
|
-
|
|
1331
|
+
if (!worker) throw new Error("worker process should exist")
|
|
1347
1332
|
await worker.stop()
|
|
1348
|
-
|
|
1333
|
+
expect(worker.status().state).toBe("stopped")
|
|
1349
1334
|
|
|
1350
1335
|
const result = await daemon.restartProcesses({processId: "worker"})
|
|
1351
1336
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1337
|
+
expect(result.restarted).toEqual(["worker"])
|
|
1338
|
+
expect(worker.status().state).toBe("running")
|
|
1339
|
+
expect(worker.status().pid).toBeTruthy()
|
|
1355
1340
|
} finally {
|
|
1356
1341
|
await daemon.shutdown()
|
|
1357
1342
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1371,8 +1356,8 @@ test("the restart control command bounces a process over the socket", async () =
|
|
|
1371
1356
|
path: fixture.config.control.path
|
|
1372
1357
|
})
|
|
1373
1358
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1359
|
+
expect(response.restarted).toEqual(["beacon"])
|
|
1360
|
+
expect(pidsById(daemon.status()).beacon).not.toBe(before.beacon)
|
|
1376
1361
|
} finally {
|
|
1377
1362
|
await daemon.shutdown()
|
|
1378
1363
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1388,15 +1373,15 @@ test("status and events distinguish deploy starts from manual restarts", async (
|
|
|
1388
1373
|
|
|
1389
1374
|
const afterDeploy = daemon.status().services.find((service) => service.id === "beacon")
|
|
1390
1375
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1376
|
+
if (!afterDeploy) throw new Error("Missing required fixture: afterDeploy")
|
|
1377
|
+
expect(afterDeploy.process.lastStartReason).toBe("deploy")
|
|
1393
1378
|
|
|
1394
1379
|
await daemon.restartProcesses({processId: "beacon"})
|
|
1395
1380
|
|
|
1396
1381
|
const afterRestart = daemon.status().services.find((service) => service.id === "beacon")
|
|
1397
1382
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1383
|
+
if (!afterRestart) throw new Error("Missing required fixture: afterRestart")
|
|
1384
|
+
expect(afterRestart.process.lastStartReason).toBe("manual")
|
|
1400
1385
|
|
|
1401
1386
|
const events = /** @type {import("../src/event-log.js").DaemonEvent[]} */ ((await sendControlCommand({
|
|
1402
1387
|
command: {command: "events"},
|
|
@@ -1404,8 +1389,8 @@ test("status and events distinguish deploy starts from manual restarts", async (
|
|
|
1404
1389
|
})).events)
|
|
1405
1390
|
const startReasons = events.filter((event) => event.message === "process started").map((event) => event.data.reason)
|
|
1406
1391
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1392
|
+
expect({value: Boolean(startReasons.includes("deploy")), context: JSON.stringify(startReasons)}).toMatchObject({value: true})
|
|
1393
|
+
expect({value: Boolean(startReasons.includes("manual")), context: JSON.stringify(startReasons)}).toMatchObject({value: true})
|
|
1409
1394
|
} finally {
|
|
1410
1395
|
await daemon.shutdown()
|
|
1411
1396
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1434,7 +1419,8 @@ test("persists daemon state to statePath and removes it on a clean shutdown", as
|
|
|
1434
1419
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1435
1420
|
}
|
|
1436
1421
|
|
|
1437
|
-
|
|
1422
|
+
// State file removed on clean shutdown.
|
|
1423
|
+
expect(stateAfterShutdown).toBe(undefined)
|
|
1438
1424
|
})
|
|
1439
1425
|
|
|
1440
1426
|
test("persisted daemon state excludes process commands, environment values, and output", async () => {
|
|
@@ -1442,7 +1428,7 @@ test("persisted daemon state excludes process commands, environment values, and
|
|
|
1442
1428
|
const fixture = await createFixture({persistState: true})
|
|
1443
1429
|
const web = fixture.config.processes.find((processConfig) => processConfig.id === "web")
|
|
1444
1430
|
|
|
1445
|
-
|
|
1431
|
+
if (!web) throw new Error("Missing required fixture: web")
|
|
1446
1432
|
web.env.ROLLBRIDGE_TEST_SECRET = secret
|
|
1447
1433
|
web.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`console.log(process.env.ROLLBRIDGE_TEST_SECRET); import(${JSON.stringify(pathToFileURL(dummyAppPath).href)})`)}`
|
|
1448
1434
|
|
|
@@ -1452,20 +1438,21 @@ test("persisted daemon state excludes process commands, environment values, and
|
|
|
1452
1438
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1453
1439
|
const webProcess = daemon.activeRelease?.getProcess("web")
|
|
1454
1440
|
|
|
1455
|
-
|
|
1441
|
+
if (!webProcess) throw new Error("Missing required fixture: webProcess")
|
|
1456
1442
|
await recordedLogLine(webProcess, secret)
|
|
1457
|
-
|
|
1443
|
+
// Secret output must be retained before persistence.
|
|
1444
|
+
expect(webProcess.status().logs.some((entry) => entry.line === secret)).toBe(true)
|
|
1458
1445
|
|
|
1459
1446
|
daemon.persistState()
|
|
1460
1447
|
await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
|
|
1461
1448
|
|
|
1462
1449
|
const persisted = await fs.readFile(fixture.statePath, "utf8")
|
|
1463
1450
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1451
|
+
expect(persisted).not.toMatch(/state-secret-value/)
|
|
1452
|
+
expect(persisted).not.toMatch(/ROLLBRIDGE_TEST_SECRET/)
|
|
1453
|
+
expect(persisted).not.toMatch(/"command"/)
|
|
1454
|
+
expect(persisted).not.toMatch(/"logs"/)
|
|
1455
|
+
expect(liveProcesses(JSON.parse(persisted), () => true).map(({id, releaseId}) => ({id, releaseId}))).toEqual([{id: "web", releaseId: "v1"}])
|
|
1469
1456
|
} finally {
|
|
1470
1457
|
await daemon.shutdown()
|
|
1471
1458
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1482,7 +1469,8 @@ test("a clean shutdown clears the state file even when a persist write is in fli
|
|
|
1482
1469
|
// Shut down immediately — the deploy's fire-and-forget persist may still be in flight.
|
|
1483
1470
|
await daemon.shutdown()
|
|
1484
1471
|
|
|
1485
|
-
|
|
1472
|
+
// State file must not be recreated by an in-flight write.
|
|
1473
|
+
expect(await readState(fixture.statePath)).toBe(undefined)
|
|
1486
1474
|
} finally {
|
|
1487
1475
|
if (!daemon.stopping) await daemon.shutdown()
|
|
1488
1476
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1519,7 +1507,7 @@ test("reports orphaned managed processes from a previous daemon's state", async
|
|
|
1519
1507
|
|
|
1520
1508
|
await daemon.reportOrphans()
|
|
1521
1509
|
|
|
1522
|
-
|
|
1510
|
+
expect({value: Boolean(logs.some((entry) => entry.message === "orphaned managed process detected" && entry.data.pid === leftover.pid)), context: JSON.stringify(logs)}).toMatchObject({value: true})
|
|
1523
1511
|
|
|
1524
1512
|
// A dead pid is not reported.
|
|
1525
1513
|
logs.length = 0
|
|
@@ -1531,7 +1519,7 @@ test("reports orphaned managed processes from a previous daemon's state", async
|
|
|
1531
1519
|
})
|
|
1532
1520
|
await daemon.reportOrphans()
|
|
1533
1521
|
|
|
1534
|
-
|
|
1522
|
+
expect(!logs.some((entry) => entry.message === "orphaned managed process detected")).toBeTruthy()
|
|
1535
1523
|
} finally {
|
|
1536
1524
|
leftover.kill("SIGKILL")
|
|
1537
1525
|
await fs.rm(dir, {force: true, recursive: true})
|
|
@@ -1566,16 +1554,16 @@ test("status surfaces still-alive orphaned processes from a previous daemon and
|
|
|
1566
1554
|
await daemon.reportOrphans()
|
|
1567
1555
|
|
|
1568
1556
|
// status reflects the still-running child even though the daemon cannot re-manage it.
|
|
1569
|
-
|
|
1557
|
+
expect(daemon.status().orphans).toEqual([{id: "worker", pid: leftover.pid, releaseId: "v1"}])
|
|
1570
1558
|
|
|
1571
1559
|
// Once the leftover is stopped, status re-checks liveness and drops it.
|
|
1572
1560
|
leftover.kill("SIGKILL")
|
|
1573
1561
|
await waitFor(() => daemon.status().orphans.length === 0)
|
|
1574
|
-
|
|
1562
|
+
expect(daemon.status().orphans).toEqual([])
|
|
1575
1563
|
|
|
1576
1564
|
// The dead entry is pruned from the underlying list, not merely filtered, so a recycled pid
|
|
1577
1565
|
// can't resurrect a cleared orphan.
|
|
1578
|
-
|
|
1566
|
+
expect(daemon.orphans).toEqual([])
|
|
1579
1567
|
} finally {
|
|
1580
1568
|
leftover.kill("SIGKILL")
|
|
1581
1569
|
await fs.rm(dir, {force: true, recursive: true})
|
|
@@ -1596,14 +1584,14 @@ test("the daemon records a structured event history served by the events command
|
|
|
1596
1584
|
const events = /** @type {import("../src/event-log.js").DaemonEvent[]} */ (response.events)
|
|
1597
1585
|
const messages = events.map((event) => event.message)
|
|
1598
1586
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1587
|
+
expect({value: Boolean(messages.includes("deploy starting")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
1588
|
+
expect({value: Boolean(messages.includes("traffic switched")), context: JSON.stringify(messages)}).toMatchObject({value: true})
|
|
1601
1589
|
|
|
1602
1590
|
const switched = events.find((event) => event.message === "traffic switched")
|
|
1603
1591
|
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1592
|
+
if (!switched) throw new Error("Missing required fixture: switched")
|
|
1593
|
+
expect(switched.data.releaseId).toBe("v1")
|
|
1594
|
+
expect(switched.at).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/)
|
|
1607
1595
|
} finally {
|
|
1608
1596
|
await daemon.shutdown()
|
|
1609
1597
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1618,32 +1606,32 @@ test("the events command honors --limit and records failed commands", async () =
|
|
|
1618
1606
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1619
1607
|
|
|
1620
1608
|
// An unknown command is rejected and recorded as a "command failed" event.
|
|
1621
|
-
await
|
|
1609
|
+
await expect(sendControlCommand({
|
|
1622
1610
|
command: {command: "bogus"},
|
|
1623
1611
|
path: fixture.config.control.path
|
|
1624
|
-
}))
|
|
1612
|
+
})).rejects.toThrow()
|
|
1625
1613
|
|
|
1626
1614
|
const all = /** @type {import("../src/event-log.js").DaemonEvent[]} */ ((await sendControlCommand({
|
|
1627
1615
|
command: {command: "events"},
|
|
1628
1616
|
path: fixture.config.control.path
|
|
1629
1617
|
})).events)
|
|
1630
1618
|
|
|
1631
|
-
|
|
1619
|
+
expect(all.some((event) => event.message === "command failed")).toBeTruthy()
|
|
1632
1620
|
|
|
1633
1621
|
const limited = /** @type {import("../src/event-log.js").DaemonEvent[]} */ ((await sendControlCommand({
|
|
1634
1622
|
command: {command: "events", limit: 1},
|
|
1635
1623
|
path: fixture.config.control.path
|
|
1636
1624
|
})).events)
|
|
1637
1625
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1626
|
+
expect(limited.length).toBe(1)
|
|
1627
|
+
expect(limited[0]).toEqual(all[all.length - 1])
|
|
1640
1628
|
} finally {
|
|
1641
1629
|
await daemon.shutdown()
|
|
1642
1630
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1643
1631
|
}
|
|
1644
1632
|
})
|
|
1645
1633
|
|
|
1646
|
-
|
|
1634
|
+
linuxTest("a process over its memory limit is restarted with reason memory", async () => {
|
|
1647
1635
|
const limitBytes = 64 * 1024 * 1024
|
|
1648
1636
|
const fixture = await createFixture({memoryLimitBytes: limitBytes})
|
|
1649
1637
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -1656,17 +1644,17 @@ test("a process over its memory limit is restarted with reason memory", {skip: p
|
|
|
1656
1644
|
|
|
1657
1645
|
const hog = activeProcessStatus(daemon, "hog")
|
|
1658
1646
|
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1647
|
+
if (!hog) throw new Error("hog process should be present")
|
|
1648
|
+
expect({value: Boolean(hog.memoryRestarts >= 1), context: `expected a memory restart, got ${hog.memoryRestarts}`}).toMatchObject({value: true})
|
|
1649
|
+
expect(hog.lastStartReason).toBe("memory")
|
|
1650
|
+
expect(typeof hog.lastMemoryRestartAt).toBe("string")
|
|
1663
1651
|
|
|
1664
1652
|
// Keep the replacement alive long enough to observe its next monitor sample. The fixture
|
|
1665
1653
|
// remains over the configured limit after every launch, otherwise it can restart again and
|
|
1666
1654
|
// clear rssBytes/children before this polling loop observes them on slower CI runners.
|
|
1667
1655
|
const hogProcess = daemon.activeRelease?.processes.get("hog")
|
|
1668
1656
|
|
|
1669
|
-
|
|
1657
|
+
if (!hogProcess?.memory) throw new Error("Missing required fixture: hogProcess?.memory")
|
|
1670
1658
|
hogProcess.memory.limitBytes = Number.MAX_SAFE_INTEGER
|
|
1671
1659
|
|
|
1672
1660
|
// rssBytes is sampled on the monitor's interval; wait for a measurement of the running process.
|
|
@@ -1679,9 +1667,9 @@ test("a process over its memory limit is restarted with reason memory", {skip: p
|
|
|
1679
1667
|
// The same monitor sample reports the process tree.
|
|
1680
1668
|
const monitored = activeProcessStatus(daemon, "hog")
|
|
1681
1669
|
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1670
|
+
if (!monitored) throw new Error("Missing required fixture: monitored")
|
|
1671
|
+
expect(monitored.children.length >= 1).toBe(true)
|
|
1672
|
+
expect(monitored.children.some((child) => typeof child.rssBytes === "number" && child.rssBytes > 0)).toBeTruthy()
|
|
1685
1673
|
} finally {
|
|
1686
1674
|
await daemon.shutdown()
|
|
1687
1675
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1696,14 +1684,14 @@ test("rollback re-activates the previous release and switches traffic back", asy
|
|
|
1696
1684
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1697
1685
|
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
1698
1686
|
|
|
1699
|
-
|
|
1687
|
+
expect(await fetchText(daemon, "/release")).toBe("v2")
|
|
1700
1688
|
|
|
1701
1689
|
const result = await daemon.rollback()
|
|
1702
1690
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1691
|
+
expect(result.activeReleaseId).toBe("v1")
|
|
1692
|
+
expect(result.previousReleaseId).toBe("v2")
|
|
1693
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
1694
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
1707
1695
|
} finally {
|
|
1708
1696
|
await daemon.shutdown()
|
|
1709
1697
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1721,8 +1709,8 @@ test("rollback --release-id targets a specific retained release", async () => {
|
|
|
1721
1709
|
|
|
1722
1710
|
const result = await daemon.rollback({releaseId: "v1"})
|
|
1723
1711
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1712
|
+
expect(result.activeReleaseId).toBe("v1")
|
|
1713
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
1726
1714
|
} finally {
|
|
1727
1715
|
await daemon.shutdown()
|
|
1728
1716
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1736,9 +1724,9 @@ test("rollback rejects no-previous, unknown, and already-active targets", async
|
|
|
1736
1724
|
try {
|
|
1737
1725
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
1738
1726
|
|
|
1739
|
-
await
|
|
1740
|
-
await
|
|
1741
|
-
await
|
|
1727
|
+
await expect(daemon.rollback()).rejects.toThrow(/No previous release/)
|
|
1728
|
+
await expect(daemon.rollback({releaseId: "v1"})).rejects.toThrow(/already active/)
|
|
1729
|
+
await expect(daemon.rollback({releaseId: "nope"})).rejects.toThrow(/No retained release "nope"/)
|
|
1742
1730
|
} finally {
|
|
1743
1731
|
await daemon.shutdown()
|
|
1744
1732
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1760,17 +1748,17 @@ test("rollback to a still-draining release stops the old instance instead of orp
|
|
|
1760
1748
|
|
|
1761
1749
|
const draining = statusRelease(daemon, "v1")
|
|
1762
1750
|
|
|
1763
|
-
|
|
1751
|
+
expect(draining.state).toBe("draining")
|
|
1764
1752
|
|
|
1765
1753
|
const oldWebPid = draining.processes.find((processStatus) => processStatus.id === "web")?.pid
|
|
1766
1754
|
|
|
1767
|
-
|
|
1755
|
+
if (!oldWebPid) throw new Error("the draining release should have a running web process")
|
|
1768
1756
|
|
|
1769
1757
|
await daemon.rollback({releaseId: "v1"})
|
|
1770
1758
|
|
|
1771
|
-
|
|
1759
|
+
expect(daemon.status().activeReleaseId).toBe("v1")
|
|
1772
1760
|
// The old draining instance was stopped before its id was reused, so its process is gone.
|
|
1773
|
-
|
|
1761
|
+
await expect(() => process.kill(/** @type {number} */ (oldWebPid), 0)).toThrow(/ESRCH/)
|
|
1774
1762
|
} finally {
|
|
1775
1763
|
if (socket) socket.close()
|
|
1776
1764
|
await daemon.shutdown()
|
|
@@ -1791,8 +1779,8 @@ test("the rollback control command switches traffic over the socket", async () =
|
|
|
1791
1779
|
path: fixture.config.control.path
|
|
1792
1780
|
})
|
|
1793
1781
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1782
|
+
expect(response.activeReleaseId).toBe("v1")
|
|
1783
|
+
expect(await fetchText(daemon, "/release")).toBe("v1")
|
|
1796
1784
|
} finally {
|
|
1797
1785
|
await daemon.shutdown()
|
|
1798
1786
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1819,8 +1807,8 @@ test("control socket accepts deploy and status commands", async () => {
|
|
|
1819
1807
|
path: fixture.config.control.path
|
|
1820
1808
|
})
|
|
1821
1809
|
|
|
1822
|
-
|
|
1823
|
-
|
|
1810
|
+
expect(status.activeReleaseId).toBe("control-v1")
|
|
1811
|
+
expect(await fetchText(daemon, "/release")).toBe("control-v1")
|
|
1824
1812
|
} finally {
|
|
1825
1813
|
await daemon.shutdown()
|
|
1826
1814
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1836,28 +1824,23 @@ test("starting a second daemon on a live control socket reports the running daem
|
|
|
1836
1824
|
|
|
1837
1825
|
const second = new RollbridgeDaemon({config: fixture.config, logger: () => {}})
|
|
1838
1826
|
|
|
1839
|
-
|
|
1840
|
-
() => second.prepareControlSocketPath(),
|
|
1841
|
-
(error) => {
|
|
1842
|
-
assert.ok(error instanceof Error)
|
|
1843
|
-
assert.match(error.message, /A Rollbridge daemon for application "rollbridge-test" is already running/)
|
|
1844
|
-
assert.match(error.message, /active release: v1/)
|
|
1845
|
-
assert.match(error.message, /rollbridge shutdown/)
|
|
1827
|
+
const preparation = second.prepareControlSocketPath()
|
|
1846
1828
|
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
)
|
|
1829
|
+
await expect(preparation).rejects.toBeInstanceOf(Error)
|
|
1830
|
+
await expect(preparation).rejects.toMatchObject({message: expect.stringMatching(/A Rollbridge daemon for application "rollbridge-test" is already running/)})
|
|
1831
|
+
await expect(preparation).rejects.toMatchObject({message: expect.stringMatching(/active release: v1/)})
|
|
1832
|
+
await expect(preparation).rejects.toMatchObject({message: expect.stringMatching(/rollbridge shutdown/)})
|
|
1850
1833
|
|
|
1851
1834
|
// The original daemon keeps its socket and still answers control commands.
|
|
1852
1835
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.config.control.path})
|
|
1853
|
-
|
|
1836
|
+
expect(status.application).toBe("rollbridge-test")
|
|
1854
1837
|
} finally {
|
|
1855
1838
|
await daemon.shutdown()
|
|
1856
1839
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1857
1840
|
}
|
|
1858
1841
|
})
|
|
1859
1842
|
|
|
1860
|
-
|
|
1843
|
+
linuxTest("the daemon applies control.owner and control.group to the bound socket", async () => {
|
|
1861
1844
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
|
|
1862
1845
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
1863
1846
|
const {uid, username} = os.userInfo()
|
|
@@ -1877,8 +1860,8 @@ test("the daemon applies control.owner and control.group to the bound socket", {
|
|
|
1877
1860
|
|
|
1878
1861
|
const stats = await fs.stat(socketPath)
|
|
1879
1862
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1863
|
+
expect(stats.uid).toBe(uid)
|
|
1864
|
+
expect(stats.gid).toBe(gid)
|
|
1882
1865
|
} finally {
|
|
1883
1866
|
await daemon.shutdown()
|
|
1884
1867
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -1907,10 +1890,7 @@ test("a control socket held by a non-Rollbridge process reports a generic confli
|
|
|
1907
1890
|
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
1908
1891
|
|
|
1909
1892
|
try {
|
|
1910
|
-
await
|
|
1911
|
-
() => daemon.prepareControlSocketPath(),
|
|
1912
|
-
/The control socket .* is already in use by another process/
|
|
1913
|
-
)
|
|
1893
|
+
await expect(daemon.prepareControlSocketPath()).rejects.toThrow(/The control socket .* is already in use by another process/)
|
|
1914
1894
|
} finally {
|
|
1915
1895
|
for (const socket of connections) socket.destroy()
|
|
1916
1896
|
await new Promise((resolve) => stranger.close(() => resolve(undefined)))
|
|
@@ -1934,7 +1914,7 @@ test("applies the configured control socket permission mode", async () => {
|
|
|
1934
1914
|
try {
|
|
1935
1915
|
const stats = await fs.stat(socketPath)
|
|
1936
1916
|
|
|
1937
|
-
|
|
1917
|
+
expect(stats.mode & 0o777).toBe(0o660)
|
|
1938
1918
|
} finally {
|
|
1939
1919
|
await daemon.shutdown()
|
|
1940
1920
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -1974,10 +1954,10 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1974
1954
|
|
|
1975
1955
|
const proxy = /** @type {{port: number}} */ (status.proxy)
|
|
1976
1956
|
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1957
|
+
expect(status.activeReleaseId).toBe("ensured-v1")
|
|
1958
|
+
expect(status.bootstrap).toBe(undefined)
|
|
1959
|
+
expect(await fs.readFile(pidPath, "utf8")).toMatch(/\d+/)
|
|
1960
|
+
expect(await fetchTextFromPort(proxy.port, "/release")).toBe("ensured-v1")
|
|
1981
1961
|
} finally {
|
|
1982
1962
|
try {
|
|
1983
1963
|
await sendControlCommand({
|
|
@@ -2156,7 +2136,7 @@ async function fetchText(daemon, pathName) {
|
|
|
2156
2136
|
async function fetchTextFromPort(port, pathName) {
|
|
2157
2137
|
const response = await fetch(`http://127.0.0.1:${port}${pathName}`)
|
|
2158
2138
|
|
|
2159
|
-
|
|
2139
|
+
expect(response.status).toBe(200)
|
|
2160
2140
|
|
|
2161
2141
|
return (await response.text()).trim()
|
|
2162
2142
|
}
|
|
@@ -2185,7 +2165,7 @@ function statusRelease(daemon, releaseId) {
|
|
|
2185
2165
|
const status = daemon.status()
|
|
2186
2166
|
const release = status.releases.find((candidate) => candidate.releaseId === releaseId)
|
|
2187
2167
|
|
|
2188
|
-
|
|
2168
|
+
if (!release) throw new Error(`Release ${releaseId} should be present`)
|
|
2189
2169
|
|
|
2190
2170
|
return release
|
|
2191
2171
|
}
|
|
@@ -2305,3 +2285,4 @@ function activeProcessStatus(daemon, processId) {
|
|
|
2305
2285
|
|
|
2306
2286
|
return release ? release.processes.find((processStatus) => processStatus.id === processId) : undefined
|
|
2307
2287
|
}
|
|
2288
|
+
})
|