rollbridge 0.1.39 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +12 -1
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +16 -2
- package/changelog.d/20260830055159-guardian-daemon-restart.md +2 -0
- package/docs/cli.md +23 -9
- package/docs/config.md +37 -13
- package/docs/logging.md +4 -3
- package/docs/troubleshooting.md +7 -5
- package/examples/tensorbuzz.com.js +5 -2
- package/package.json +1 -1
- package/src/cli.js +118 -24
- package/src/config.js +35 -8
- package/src/daemon.js +822 -152
- package/src/guardian-client.js +121 -16
- package/src/managed-process.js +117 -15
- package/src/process-guardian.js +734 -43
- package/src/release-group.js +45 -7
- package/test/completion.test.js +4 -2
- package/test/config-examples.test.js +1 -0
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/guardian-recovery-owner.js +86 -0
- package/test/fixtures/pre-split3-process-guardian.js +14 -0
- package/test/guardian-client.test.js +1420 -62
- package/test/managed-process.test.js +163 -7
- package/test/owner-recovery.test.js +575 -73
- package/test/owner-replacement.test.js +525 -28
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +263 -13
- package/test/support/process.js +41 -0
|
@@ -7,9 +7,10 @@ import {normalizeConfig} from "../src/config.js"
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
|
|
10
|
+
* @param {() => boolean} [shouldStart] - Whether process starts remain allowed.
|
|
10
11
|
* @returns {ReleaseGroup} A release group ready for buildProcess.
|
|
11
12
|
*/
|
|
12
|
-
function buildRelease(webProcess) {
|
|
13
|
+
function buildRelease(webProcess, shouldStart = () => true) {
|
|
13
14
|
const config = normalizeConfig({
|
|
14
15
|
application: "demo",
|
|
15
16
|
control: {path: "/tmp/rollbridge-release-group.sock"},
|
|
@@ -17,7 +18,7 @@ function buildRelease(webProcess) {
|
|
|
17
18
|
proxy: {host: "127.0.0.1", port: 0}
|
|
18
19
|
})
|
|
19
20
|
|
|
20
|
-
return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1"})
|
|
21
|
+
return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1", shouldStart})
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
test("templates interpolate values from the daemon environment", () => {
|
|
@@ -78,3 +79,19 @@ test("a referenced daemon environment variable that is unset fails fast", () =>
|
|
|
78
79
|
/Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
|
|
79
80
|
)
|
|
80
81
|
})
|
|
82
|
+
|
|
83
|
+
test("committed generation restoration does not start after shutdown begins", async () => {
|
|
84
|
+
const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}}, () => false)
|
|
85
|
+
const process = release.buildProcess(release.config.processes[0])
|
|
86
|
+
let starts = 0
|
|
87
|
+
|
|
88
|
+
release.state = "draining"
|
|
89
|
+
process.start = async () => {
|
|
90
|
+
starts += 1
|
|
91
|
+
throw new Error("process started after shutdown")
|
|
92
|
+
}
|
|
93
|
+
release.processes.set("web", process)
|
|
94
|
+
|
|
95
|
+
await assert.rejects(() => release.restartCommittedGeneration(), /shutting down/)
|
|
96
|
+
assert.equal(starts, 0)
|
|
97
|
+
})
|
|
@@ -10,6 +10,7 @@ import path from "node:path"
|
|
|
10
10
|
import test from "node:test"
|
|
11
11
|
import {fileURLToPath} from "node:url"
|
|
12
12
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
+
import GuardianClient from "../src/guardian-client.js"
|
|
13
14
|
|
|
14
15
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
15
16
|
const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
|
|
@@ -23,12 +24,14 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
23
24
|
const logPath = path.join(root, "daemon.log")
|
|
24
25
|
const pidPath = path.join(root, "daemon.pid")
|
|
25
26
|
const runtimePath = path.join(root, "runtime")
|
|
27
|
+
const statePath = path.join(root, "state.json")
|
|
26
28
|
|
|
27
29
|
try {
|
|
28
30
|
await Promise.all([prepareRelease(releaseA, true), prepareRelease(releaseB, true)])
|
|
29
31
|
await fs.writeFile(configPath, `export default ${JSON.stringify({
|
|
30
32
|
application: "runtime-retention-test",
|
|
31
33
|
control: {path: socketPath},
|
|
34
|
+
ownerRecovery: {reconnectGraceMs: 50},
|
|
32
35
|
processes: [{
|
|
33
36
|
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
34
37
|
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
@@ -36,7 +39,8 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
36
39
|
policy: "proxied",
|
|
37
40
|
port: {from: 0, to: 0}
|
|
38
41
|
}],
|
|
39
|
-
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
|
|
42
|
+
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0},
|
|
43
|
+
statePath
|
|
40
44
|
}, null, 2)}\n`)
|
|
41
45
|
|
|
42
46
|
await runReleaseCli(releaseA, [
|
|
@@ -52,7 +56,7 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
52
56
|
"--daemon-runtime-path", runtimePath
|
|
53
57
|
])
|
|
54
58
|
|
|
55
|
-
await fs.rm(
|
|
59
|
+
await fs.rm(releaseA, {recursive: true})
|
|
56
60
|
|
|
57
61
|
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
58
62
|
const proxyPort = /** @type {{port: number}} */ (status.proxy).port
|
|
@@ -66,14 +70,67 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
66
70
|
assert.ok(!runtime.path.startsWith(releaseA), `runtime must be outside release A: ${runtime.path}`)
|
|
67
71
|
assert.equal(response.status, 200)
|
|
68
72
|
assert.equal(await response.text(), "deferred runtime loaded\n")
|
|
73
|
+
assert.equal(typeof status.daemonPid, "number")
|
|
74
|
+
const daemonPid = /** @type {number} */ (status.daemonPid)
|
|
75
|
+
|
|
76
|
+
process.kill(daemonPid, "SIGKILL")
|
|
77
|
+
const recoveredPid = await waitForChangedPid(pidPath, daemonPid)
|
|
78
|
+
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
79
|
+
|
|
80
|
+
assert.equal(recovered.daemonPid, recoveredPid)
|
|
81
|
+
assert.equal(recovered.activeReleaseId, "B")
|
|
69
82
|
} finally {
|
|
70
83
|
try {
|
|
71
84
|
await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
72
85
|
} catch {
|
|
73
86
|
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
74
|
-
if (Number.isInteger(pid))
|
|
87
|
+
if (Number.isInteger(pid)) killProcessIfAlive(pid)
|
|
75
88
|
}
|
|
89
|
+
await stopGuardian(statePath)
|
|
90
|
+
|
|
91
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("ensure-daemon resolves an explicit relative config before changing to its durable directory", async () => {
|
|
96
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-relative-config-"))
|
|
97
|
+
const release = path.join(root, "release")
|
|
98
|
+
const configDirectory = path.join(release, "configs")
|
|
99
|
+
const configPath = path.join(configDirectory, "rollbridge.js")
|
|
100
|
+
const pidPath = path.join(root, "daemon.pid")
|
|
101
|
+
const socketPath = path.join(root, "control.sock")
|
|
102
|
+
const statePath = path.join(root, "state.json")
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await prepareRelease(release, false)
|
|
106
|
+
await fs.mkdir(configDirectory)
|
|
107
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify({
|
|
108
|
+
...basicConfig(path.relative(configDirectory, socketPath)),
|
|
109
|
+
ownerRecovery: {reconnectGraceMs: 50},
|
|
110
|
+
processes: [{
|
|
111
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
112
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
113
|
+
id: "web",
|
|
114
|
+
policy: "proxied",
|
|
115
|
+
port: {from: 0, to: 0}
|
|
116
|
+
}],
|
|
117
|
+
statePath: path.relative(configDirectory, statePath)
|
|
118
|
+
}, null, 2)}\n`)
|
|
119
|
+
await runReleaseCli(release, [
|
|
120
|
+
"deploy", "--ensure-daemon", "--config", path.relative(release, configPath),
|
|
121
|
+
"--release-path", release, "--release-id", "relative-config",
|
|
122
|
+
"--daemon-log-path", path.join(root, "daemon.log"), "--daemon-pid-path", pidPath,
|
|
123
|
+
"--daemon-runtime-path", path.join(root, "runtime"), "--daemon-start-timeout-ms", "1000"
|
|
124
|
+
])
|
|
125
|
+
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
126
|
+
|
|
127
|
+
assert.equal(status.activeReleaseId, "relative-config")
|
|
128
|
+
} finally {
|
|
129
|
+
await sendControlCommand({command: {command: "shutdown"}, path: socketPath}).catch(() => undefined)
|
|
130
|
+
await stopGuardian(statePath)
|
|
131
|
+
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
76
132
|
|
|
133
|
+
if (Number.isInteger(pid)) killProcessIfAlive(pid)
|
|
77
134
|
await fs.rm(root, {force: true, recursive: true})
|
|
78
135
|
}
|
|
79
136
|
})
|
|
@@ -349,7 +406,7 @@ function basicConfig(socketPath) {
|
|
|
349
406
|
*/
|
|
350
407
|
async function runReleaseCli(releasePath, args) {
|
|
351
408
|
const binPath = path.join(releasePath, "node_modules", "rollbridge", "bin", "rollbridge")
|
|
352
|
-
const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
409
|
+
const child = spawn(process.execPath, [binPath, ...args], {cwd: releasePath, stdio: ["ignore", "pipe", "pipe"]})
|
|
353
410
|
let output = ""
|
|
354
411
|
|
|
355
412
|
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
@@ -358,3 +415,63 @@ async function runReleaseCli(releasePath, args) {
|
|
|
358
415
|
|
|
359
416
|
if (code !== 0) throw new Error(output)
|
|
360
417
|
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} pidPath - Daemon PID file.
|
|
421
|
+
* @param {number} previousPid - Exited daemon PID.
|
|
422
|
+
* @returns {Promise<number>} Recovered daemon PID.
|
|
423
|
+
*/
|
|
424
|
+
async function waitForChangedPid(pidPath, previousPid) {
|
|
425
|
+
const deadline = Date.now() + 5000
|
|
426
|
+
|
|
427
|
+
while (Date.now() < deadline) {
|
|
428
|
+
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
429
|
+
|
|
430
|
+
if (Number.isInteger(pid) && pid !== previousPid) return pid
|
|
431
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
432
|
+
}
|
|
433
|
+
throw new Error(`Timed out waiting for ${pidPath} to publish a recovered daemon PID`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** @param {number} pid - Exact fixture process PID. */
|
|
437
|
+
function killProcessIfAlive(pid) {
|
|
438
|
+
try {
|
|
439
|
+
process.kill(pid, "SIGKILL")
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** @param {string} statePath - Owner-recovery state path. */
|
|
446
|
+
async function stopGuardian(statePath) {
|
|
447
|
+
try {
|
|
448
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
449
|
+
const identity = state.recovery?.guardian
|
|
450
|
+
const pid = identity?.pid
|
|
451
|
+
|
|
452
|
+
if (identity?.socketPath && identity.token) {
|
|
453
|
+
const client = new GuardianClient(identity)
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
await client.connect()
|
|
457
|
+
for (const entry of await client.inventory()) {
|
|
458
|
+
if (entry.status.pid) {
|
|
459
|
+
try { process.kill(-entry.status.pid, "SIGKILL") } catch (error) {
|
|
460
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
client.disconnect()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (typeof pid === "number") {
|
|
469
|
+
const command = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8")).replaceAll("\0", " ")
|
|
470
|
+
|
|
471
|
+
if (!command.includes("process-guardian.js") || !command.includes(statePath)) throw new Error(`Refusing to stop unverified fixture guardian pid ${pid}`)
|
|
472
|
+
process.kill(pid, "SIGKILL")
|
|
473
|
+
}
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
|
|
476
|
+
}
|
|
477
|
+
}
|
package/test/rollbridge.test.js
CHANGED
|
@@ -525,6 +525,27 @@ test("opt-in generation lifecycle retires the old generation before activating a
|
|
|
525
525
|
}
|
|
526
526
|
})
|
|
527
527
|
|
|
528
|
+
test("manual restart reaches the active handoff coordinator and restores its lifecycle role", async () => {
|
|
529
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
|
|
530
|
+
const daemon = await startDaemon(fixture.config)
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
534
|
+
const before = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
|
|
535
|
+
const result = await daemon.restartProcesses({processId: "beacon"})
|
|
536
|
+
const after = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
|
|
537
|
+
|
|
538
|
+
assert.deepEqual(result, {restarted: ["beacon"]})
|
|
539
|
+
assert.ok(before)
|
|
540
|
+
assert.ok(after)
|
|
541
|
+
assert.notEqual(after, before)
|
|
542
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v1"])
|
|
543
|
+
} finally {
|
|
544
|
+
await daemon.shutdown()
|
|
545
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
|
|
528
549
|
test("generation commit is durable before awaited post-transition work", async () => {
|
|
529
550
|
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
|
|
530
551
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -639,25 +660,248 @@ test("retirement failure retains the exact transition, blocks other deploys, and
|
|
|
639
660
|
}
|
|
640
661
|
})
|
|
641
662
|
|
|
642
|
-
test("candidate activation failure
|
|
663
|
+
test("candidate activation failure reports restoration failure and exact recovery clears the fence", async () => {
|
|
643
664
|
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
644
665
|
const daemon = await startDaemon(fixture.config)
|
|
645
666
|
|
|
646
667
|
try {
|
|
647
668
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
648
|
-
|
|
669
|
+
const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
|
|
670
|
+
const reactivate = incumbentCoordinator?.reactivateStrict.bind(incumbentCoordinator)
|
|
671
|
+
|
|
672
|
+
assert.ok(incumbentCoordinator && reactivate)
|
|
673
|
+
incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
|
|
674
|
+
await assert.rejects(
|
|
675
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
676
|
+
error => {
|
|
677
|
+
assert.ok(error instanceof AggregateError)
|
|
678
|
+
assert.match(error.message, /activate command exited non-zero/)
|
|
679
|
+
assert.match(error.message, /incumbent v1 restoration failed: incumbent restoration rejected/i)
|
|
680
|
+
return true
|
|
681
|
+
}
|
|
682
|
+
)
|
|
649
683
|
|
|
650
684
|
const failed = daemon.status()
|
|
651
685
|
|
|
652
686
|
assert.equal(failed.activeReleaseId, "v1")
|
|
653
|
-
assert.equal(failed.generationTransition?.phase, "
|
|
654
|
-
assert.
|
|
687
|
+
assert.equal(failed.generationTransition?.phase, "restoring_previous")
|
|
688
|
+
assert.match(String(failed.generationTransition?.activationError), /activate command exited non-zero/)
|
|
689
|
+
assert.match(String(failed.generationTransition?.compensationError), /incumbent restoration rejected/)
|
|
690
|
+
const failedEvents = daemon.eventLog.recent()
|
|
691
|
+
const activationEvent = failedEvents.find((event) => event.message === "release generation activation failed")
|
|
692
|
+
const restorationEvent = failedEvents.find((event) => event.message === "release generation compensation restoration failed")
|
|
693
|
+
|
|
694
|
+
assert.match(String(activationEvent?.data.error), /activate command exited non-zero/)
|
|
695
|
+
assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
|
|
696
|
+
assert.match(String(restorationEvent?.data.error), /incumbent restoration rejected/)
|
|
697
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2"])
|
|
698
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
|
|
655
699
|
|
|
656
|
-
|
|
657
|
-
await
|
|
700
|
+
incumbentCoordinator.reactivateStrict = reactivate
|
|
701
|
+
const recovery = await sendControlCommand({
|
|
702
|
+
command: {
|
|
703
|
+
command: "recover-generation-transition",
|
|
704
|
+
previousReleaseId: "v1",
|
|
705
|
+
releaseId: "v2",
|
|
706
|
+
releasePath: fixture.root,
|
|
707
|
+
revision: "v2"
|
|
708
|
+
},
|
|
709
|
+
path: fixture.config.control.path
|
|
710
|
+
})
|
|
658
711
|
|
|
659
|
-
assert.equal(
|
|
660
|
-
assert.
|
|
712
|
+
assert.equal(recovery.recoveryStatus, "recovered")
|
|
713
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
714
|
+
assert.equal(daemon.status().generationTransition, undefined)
|
|
715
|
+
const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
|
|
716
|
+
|
|
717
|
+
assert.equal(persisted?.generationTransition, undefined)
|
|
718
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
719
|
+
|
|
720
|
+
const idempotent = await sendControlCommand({
|
|
721
|
+
command: {
|
|
722
|
+
command: "recover-generation-transition",
|
|
723
|
+
previousReleaseId: "v1",
|
|
724
|
+
releaseId: "v2",
|
|
725
|
+
releasePath: fixture.root,
|
|
726
|
+
revision: "v2"
|
|
727
|
+
},
|
|
728
|
+
path: fixture.config.control.path
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
assert.equal(idempotent.recoveryStatus, "already_recovered")
|
|
732
|
+
await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
|
|
733
|
+
await assert.rejects(
|
|
734
|
+
() => sendControlCommand({
|
|
735
|
+
command: {
|
|
736
|
+
command: "recover-generation-transition",
|
|
737
|
+
previousReleaseId: "v1",
|
|
738
|
+
releaseId: "v3",
|
|
739
|
+
releasePath: fixture.root,
|
|
740
|
+
revision: "v3"
|
|
741
|
+
},
|
|
742
|
+
path: fixture.config.control.path
|
|
743
|
+
}),
|
|
744
|
+
/not a safe failed pre-commit transition/i
|
|
745
|
+
)
|
|
746
|
+
} finally {
|
|
747
|
+
await daemon.shutdown()
|
|
748
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
test("candidate activation failure compensates to the incumbent and admits a different later release", async () => {
|
|
753
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
754
|
+
const daemon = await startDaemon(fixture.config)
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
758
|
+
await assert.rejects(
|
|
759
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
760
|
+
/activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
const compensated = daemon.status()
|
|
764
|
+
|
|
765
|
+
assert.equal(compensated.activeReleaseId, "v1")
|
|
766
|
+
assert.equal(compensated.generationTransition, undefined)
|
|
767
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
768
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
|
|
769
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
770
|
+
|
|
771
|
+
await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
|
|
772
|
+
|
|
773
|
+
assert.equal(daemon.status().activeReleaseId, "v3")
|
|
774
|
+
assert.equal(await fetchText(daemon, "/release"), "v3")
|
|
775
|
+
} finally {
|
|
776
|
+
await daemon.shutdown()
|
|
777
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
778
|
+
}
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
test("ambiguous candidate activation retires the candidate before reactivating the incumbent", async () => {
|
|
782
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateAmbiguousFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
783
|
+
const daemon = await startDaemon(fixture.config)
|
|
784
|
+
|
|
785
|
+
try {
|
|
786
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
787
|
+
await assert.rejects(
|
|
788
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
789
|
+
/activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), [
|
|
793
|
+
"activate:v1",
|
|
794
|
+
"retire:v1",
|
|
795
|
+
"activate:v2",
|
|
796
|
+
"retire:v2",
|
|
797
|
+
"activate:v1"
|
|
798
|
+
])
|
|
799
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
800
|
+
assert.equal(daemon.status().generationTransition, undefined)
|
|
801
|
+
} finally {
|
|
802
|
+
await daemon.shutdown()
|
|
803
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
804
|
+
}
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
test("candidate activation recovery reverses a worker-specific quiet hook before reporting active", async () => {
|
|
808
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationLifecycle: true})
|
|
809
|
+
const daemon = await startDaemon(fixture.config)
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
813
|
+
await assert.rejects(
|
|
814
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
815
|
+
/compensation restored incumbent v1 as authoritative/i
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
const events = await lifecycleEvents(fixture.lifecycleLogPath)
|
|
819
|
+
const candidateRetired = events.indexOf("worker-retire:v2")
|
|
820
|
+
const workerReactivated = events.indexOf("worker-reactivate:v1")
|
|
821
|
+
|
|
822
|
+
assert.ok(candidateRetired >= 0, JSON.stringify(events))
|
|
823
|
+
assert.ok(workerReactivated > candidateRetired, JSON.stringify(events))
|
|
824
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
|
|
825
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
826
|
+
assert.equal(daemon.status().generationTransition, undefined)
|
|
827
|
+
} finally {
|
|
828
|
+
await daemon.shutdown()
|
|
829
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
test("candidate activation recovery keeps the fence when a worker-specific resume hook fails", async () => {
|
|
834
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationFailure: true, workerReactivationLifecycle: true})
|
|
835
|
+
const daemon = await startDaemon(fixture.config)
|
|
836
|
+
|
|
837
|
+
try {
|
|
838
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
839
|
+
await assert.rejects(
|
|
840
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
841
|
+
error => {
|
|
842
|
+
const failure = /** @type {Error} */ (error)
|
|
843
|
+
|
|
844
|
+
assert.match(failure.message, /activate command exited non-zero/)
|
|
845
|
+
assert.match(failure.message, /reactivate command exited non-zero/)
|
|
846
|
+
return true
|
|
847
|
+
}
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
const status = daemon.status()
|
|
851
|
+
const restorationEvent = daemon.eventLog.recent().find((event) => event.message === "release generation compensation restoration failed")
|
|
852
|
+
|
|
853
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
854
|
+
assert.equal(status.generationTransition?.phase, "restoring_previous")
|
|
855
|
+
assert.match(String(status.generationTransition?.activationError), /activate command exited non-zero/)
|
|
856
|
+
assert.match(String(status.generationTransition?.compensationError), /reactivate command exited non-zero/)
|
|
857
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "quiesced")
|
|
858
|
+
assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
|
|
859
|
+
assert.match(String(restorationEvent?.data.error), /reactivate command exited non-zero/)
|
|
860
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
|
|
861
|
+
} finally {
|
|
862
|
+
await daemon.shutdown()
|
|
863
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
864
|
+
}
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
test("compensation keeps the fence when the cleared checkpoint cannot be persisted", async () => {
|
|
868
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
869
|
+
const daemon = await startDaemon(fixture.config)
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
873
|
+
const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
|
|
874
|
+
|
|
875
|
+
daemon.checkpointGenerationTransition = async () => {
|
|
876
|
+
if (!daemon.generationTransition) throw new Error("cleared checkpoint unavailable")
|
|
877
|
+
await checkpoint()
|
|
878
|
+
}
|
|
879
|
+
await assert.rejects(
|
|
880
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
881
|
+
/activate command exited non-zero.*compensation checkpoint clear failed: cleared checkpoint unavailable/i
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
885
|
+
assert.equal(daemon.status().generationTransition?.phase, "restoring_previous")
|
|
886
|
+
const persisted = /** @type {{generationTransition?: {phase?: string}} | undefined} */ (await readState(fixture.statePath))
|
|
887
|
+
|
|
888
|
+
assert.equal(persisted?.generationTransition?.phase, "restoring_previous")
|
|
889
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
|
|
890
|
+
|
|
891
|
+
daemon.checkpointGenerationTransition = checkpoint
|
|
892
|
+
const recovery = await sendControlCommand({
|
|
893
|
+
command: {
|
|
894
|
+
command: "recover-generation-transition",
|
|
895
|
+
previousReleaseId: "v1",
|
|
896
|
+
releaseId: "v2",
|
|
897
|
+
releasePath: fixture.root,
|
|
898
|
+
revision: "v2"
|
|
899
|
+
},
|
|
900
|
+
path: fixture.config.control.path
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
assert.equal(recovery.recoveryStatus, "recovered")
|
|
904
|
+
assert.equal(daemon.status().generationTransition, undefined)
|
|
661
905
|
} finally {
|
|
662
906
|
await daemon.shutdown()
|
|
663
907
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -670,6 +914,10 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
|
|
|
670
914
|
|
|
671
915
|
try {
|
|
672
916
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
917
|
+
const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
|
|
918
|
+
|
|
919
|
+
assert.ok(incumbentCoordinator)
|
|
920
|
+
incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
|
|
673
921
|
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
|
|
674
922
|
|
|
675
923
|
await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
|
|
@@ -677,9 +925,7 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
|
|
|
677
925
|
await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
|
|
678
926
|
|
|
679
927
|
assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
|
|
680
|
-
await
|
|
681
|
-
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
682
|
-
assert.equal(await fetchText(daemon, "/release"), "v2")
|
|
928
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
683
929
|
} finally {
|
|
684
930
|
await daemon.shutdown()
|
|
685
931
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1633,7 +1879,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1633
1879
|
})
|
|
1634
1880
|
|
|
1635
1881
|
/**
|
|
1636
|
-
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
1882
|
+
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateAmbiguousFailure?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerReactivationFailure?: boolean, workerReactivationLifecycle?: boolean, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
1637
1883
|
* @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1638
1884
|
*/
|
|
1639
1885
|
async function createFixture(options = {}) {
|
|
@@ -1651,7 +1897,7 @@ async function createFixture(options = {}) {
|
|
|
1651
1897
|
if (options.includeService || options.handoffService) {
|
|
1652
1898
|
const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
|
|
1653
1899
|
const lifecycle = options.handoffServiceActivate ? {
|
|
1654
|
-
activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
|
|
1900
|
+
activateCommand: `${options.handoffServiceActivateFailure && !options.handoffServiceActivateAmbiguousFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}${options.handoffServiceActivateAmbiguousFailure ? `; [ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24` : ""}`,
|
|
1655
1901
|
quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
1656
1902
|
} : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
|
|
1657
1903
|
quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
|
|
@@ -1692,6 +1938,10 @@ async function createFixture(options = {}) {
|
|
|
1692
1938
|
processes.push({
|
|
1693
1939
|
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
|
|
1694
1940
|
id: "worker",
|
|
1941
|
+
...(options.workerReactivationLifecycle ? {lifecycle: {
|
|
1942
|
+
quietCommand: `printf 'worker-retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
|
|
1943
|
+
reactivateCommand: `${options.workerReactivationFailure ? "exit 25; " : ""}printf 'worker-reactivate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
1944
|
+
}} : {}),
|
|
1695
1945
|
nonBlockingDrain: true,
|
|
1696
1946
|
policy: "companion"
|
|
1697
1947
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports whether an exact fixture process can still run. Linux keeps exited children in
|
|
7
|
+
* procfs until they are reaped, so kill(2) alone cannot distinguish a zombie from a live process.
|
|
8
|
+
* @param {number} pid - Exact fixture process PID.
|
|
9
|
+
* @returns {boolean} Whether the process can still run.
|
|
10
|
+
*/
|
|
11
|
+
export function isProcessRunning(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (process.platform !== "linux") return true
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8")
|
|
23
|
+
const state = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0]
|
|
24
|
+
|
|
25
|
+
return state !== "Z" && state !== "X"
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {number} pid - Exact fixture process PID.
|
|
34
|
+
* @param {number} [timeoutMs] - Bounded exit wait.
|
|
35
|
+
*/
|
|
36
|
+
export async function waitForProcessExit(pid, timeoutMs = 5000) {
|
|
37
|
+
const deadline = Date.now() + timeoutMs
|
|
38
|
+
|
|
39
|
+
while (isProcessRunning(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
40
|
+
if (isProcessRunning(pid)) throw new Error(`Timed out waiting for process ${pid} to exit`)
|
|
41
|
+
}
|