rollbridge 0.1.39 → 0.1.40
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/package.json +1 -1
- package/src/cli.js +93 -24
- package/src/config.js +14 -6
- package/src/daemon.js +643 -150
- package/src/guardian-client.js +68 -16
- package/src/managed-process.js +55 -8
- package/src/process-guardian.js +731 -43
- package/src/release-group.js +23 -7
- package/test/config-validation.test.js +4 -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 +1339 -62
- package/test/managed-process.test.js +136 -7
- package/test/owner-recovery.test.js +564 -55
- package/test/owner-replacement.test.js +526 -23
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +21 -0
- package/test/support/process.js +41 -0
|
@@ -13,6 +13,7 @@ import {normalizeConfig} from "../src/config.js"
|
|
|
13
13
|
import {sendControlCommand} from "../src/control-client.js"
|
|
14
14
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
15
15
|
import GuardianClient from "../src/guardian-client.js"
|
|
16
|
+
import {isProcessRunning, waitForProcessExit} from "./support/process.js"
|
|
16
17
|
|
|
17
18
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
18
19
|
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
@@ -20,7 +21,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
|
20
21
|
const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
|
|
21
22
|
|
|
22
23
|
/** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
|
|
23
|
-
/** @typedef {DaemonStatus & {recovery: {configDigest: string}, singletonReleaseIds?: Record<string, string>}} RecoveryState */
|
|
24
|
+
/** @typedef {DaemonStatus & {recovery: {configDigest: string}, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} RecoveryState */
|
|
24
25
|
|
|
25
26
|
test("external owner retirement releases guardian authority without losing its generation", async () => {
|
|
26
27
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
|
|
@@ -85,7 +86,7 @@ test("external owner retirement releases guardian authority without losing its g
|
|
|
85
86
|
await waitForFile(path.join(v1Path, "drain-started"), 1000)
|
|
86
87
|
await fs.writeFile(path.join(v1Path, "drained"), "done\n")
|
|
87
88
|
await waitForProcessExit(v1WorkerPid, 1000)
|
|
88
|
-
assert.equal(
|
|
89
|
+
assert.equal(isProcessRunning(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
|
|
89
90
|
} finally {
|
|
90
91
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
|
|
91
92
|
await replacement?.shutdown().catch(() => {})
|
|
@@ -134,6 +135,12 @@ test("exact bootstrap restores the committed generation after external owner ret
|
|
|
134
135
|
const recoveredService = recovered.services.get("beacon")
|
|
135
136
|
|
|
136
137
|
assert.ok(recoveredCandidate && recoveredService && recovered.singletons.get("singleton"))
|
|
138
|
+
recovered.serviceReleaseIds.set("beacon", "v1")
|
|
139
|
+
assert.throws(
|
|
140
|
+
() => recovered?.assertCommittedBootstrapRecoveryReady(),
|
|
141
|
+
/service beacon belongs to retained release v1/
|
|
142
|
+
)
|
|
143
|
+
recovered.serviceReleaseIds.set("beacon", "v2")
|
|
137
144
|
const activateGeneration = recoveredCandidate.activateGeneration.bind(recoveredCandidate)
|
|
138
145
|
const startService = recoveredService.start.bind(recoveredService)
|
|
139
146
|
const replaceSingletons = recovered.replaceSingletons.bind(recovered)
|
|
@@ -158,7 +165,7 @@ test("exact bootstrap restores the committed generation after external owner ret
|
|
|
158
165
|
assert.equal(active.generationTransition?.phase, "committed")
|
|
159
166
|
assert.equal(active.releases.find(({releaseId}) => releaseId === "v1")?.state, "draining")
|
|
160
167
|
assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
|
|
161
|
-
assert.equal(
|
|
168
|
+
assert.equal(isProcessRunning(v1WorkerPid), true, "the retained previous generation must keep draining")
|
|
162
169
|
assert.equal(active.releases.find(({releaseId}) => releaseId === "v2")?.state, "active")
|
|
163
170
|
assert.ok(active.releases.find(({releaseId}) => releaseId === "v2")?.processes.every(({pid, state}) => typeof pid === "number" && state === "running"))
|
|
164
171
|
assert.ok(active.services.every(({process}) => typeof process.pid === "number" && process.state === "running"))
|
|
@@ -180,6 +187,68 @@ test("exact bootstrap restores the committed generation after external owner ret
|
|
|
180
187
|
}
|
|
181
188
|
})
|
|
182
189
|
|
|
190
|
+
test("exact bootstrap restores a committed generation after its previous release was pruned", async () => {
|
|
191
|
+
const fixture = await createFixture()
|
|
192
|
+
|
|
193
|
+
fixture.config.processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
194
|
+
.filter((processConfig) => processConfig.id !== "beacon" && processConfig.id !== "singleton")
|
|
195
|
+
const worker = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
196
|
+
.find((processConfig) => processConfig.id === "worker")
|
|
197
|
+
|
|
198
|
+
assert.ok(worker)
|
|
199
|
+
const workerLifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (worker.lifecycle)
|
|
200
|
+
|
|
201
|
+
workerLifecycle.drainTimeoutMs = 500
|
|
202
|
+
fixture.config.releaseRetention = {keep: 0, maxAgeMs: 0}
|
|
203
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
204
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
205
|
+
const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
206
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
207
|
+
let recovered
|
|
208
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
209
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await retired.start()
|
|
213
|
+
await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
214
|
+
await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
215
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
216
|
+
await waitForState(fixture.statePath, (state) => !state.releases.some(({releaseId}) => releaseId === "v1"), AbortSignal.timeout(5000))
|
|
217
|
+
const committed = retired.status()
|
|
218
|
+
const retiredPids = committed.releases.find(({releaseId}) => releaseId === "v2")?.processes
|
|
219
|
+
.map(({pid}) => pid)
|
|
220
|
+
.filter((pid) => typeof pid === "number") || []
|
|
221
|
+
|
|
222
|
+
assert.equal(committed.releases.some(({releaseId}) => releaseId === "v1"), false)
|
|
223
|
+
await retired.retireOwner({attestation: `sha256:${"d".repeat(64)}`})
|
|
224
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
225
|
+
await Promise.all(retiredPids.map((pid) => waitForProcessExit(pid, 3000)))
|
|
226
|
+
|
|
227
|
+
recovered = new RollbridgeDaemon({
|
|
228
|
+
bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
|
|
229
|
+
config,
|
|
230
|
+
configPath: fixture.configPath,
|
|
231
|
+
logger: () => {}
|
|
232
|
+
})
|
|
233
|
+
await recovered.start({exposeControl: false})
|
|
234
|
+
await recovered.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
235
|
+
|
|
236
|
+
assert.equal(recovered.status().activeReleaseId, "v2")
|
|
237
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
238
|
+
} finally {
|
|
239
|
+
if (recovered) {
|
|
240
|
+
const activeRecovery = recovered.status().activeReleaseId === "v2"
|
|
241
|
+
const shutdown = recovered.shutdown()
|
|
242
|
+
|
|
243
|
+
if (activeRecovery) await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
244
|
+
await shutdown.catch(() => undefined)
|
|
245
|
+
}
|
|
246
|
+
retired.guardian?.disconnect()
|
|
247
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
248
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
183
252
|
test("journaled committed bootstrap recovery resumes after a restart begins", async () => {
|
|
184
253
|
const fixture = await createFixture()
|
|
185
254
|
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
@@ -237,7 +306,7 @@ test("journaled committed bootstrap recovery resumes after a restart begins", as
|
|
|
237
306
|
assert.deepEqual(active.services.map(({process}) => process.pid), restartedServicePids)
|
|
238
307
|
assert.ok(active.singletons.every(({process}) => typeof process.pid === "number" && process.state === "running"))
|
|
239
308
|
assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
|
|
240
|
-
assert.equal(
|
|
309
|
+
assert.equal(isProcessRunning(v1WorkerPid), true)
|
|
241
310
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2", "retire:v2", "activate:v2"])
|
|
242
311
|
} finally {
|
|
243
312
|
if (recovered) {
|
|
@@ -320,7 +389,7 @@ test("committed bootstrap tuple mismatches fail closed without singletons", asyn
|
|
|
320
389
|
assert.equal(preserved.generationTransition?.phase, "committed")
|
|
321
390
|
assert.equal(preserved.releases.find(({releaseId}) => releaseId === "v2")?.state, "draining")
|
|
322
391
|
assert.equal(releaseProcessPid(preserved, "v1", "worker"), v1WorkerPid)
|
|
323
|
-
assert.equal(
|
|
392
|
+
assert.equal(isProcessRunning(v1WorkerPid), true)
|
|
324
393
|
assert.equal(preserved.releases.some(({releaseId}) => releaseId === "wrong"), false)
|
|
325
394
|
|
|
326
395
|
await owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
@@ -425,6 +494,260 @@ test("replacement owner reconstructs one active and two draining generations aft
|
|
|
425
494
|
}
|
|
426
495
|
})
|
|
427
496
|
|
|
497
|
+
test("guardian restarts an abruptly exited daemon without replacing managed processes", async () => {
|
|
498
|
+
const fixture = await createFixture()
|
|
499
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
500
|
+
let recoveredDaemonPid
|
|
501
|
+
let workerPid
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
await waitForLog(owner, "control socket listening")
|
|
505
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
506
|
+
|
|
507
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
|
|
508
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
509
|
+
|
|
510
|
+
workerPid = releaseProcessPid(before, "v1", "worker")
|
|
511
|
+
const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
|
|
512
|
+
|
|
513
|
+
owner.kill("SIGKILL")
|
|
514
|
+
await once(owner, "exit")
|
|
515
|
+
await recoveredListenerLog
|
|
516
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
517
|
+
|
|
518
|
+
recoveredDaemonPid = recovered.daemonPid
|
|
519
|
+
assert.notEqual(recovered.daemonPid, before.daemonPid)
|
|
520
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
521
|
+
assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
|
|
522
|
+
|
|
523
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
524
|
+
|
|
525
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
526
|
+
await shutdown
|
|
527
|
+
} finally {
|
|
528
|
+
await killChild(owner)
|
|
529
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
530
|
+
if (workerPid) {
|
|
531
|
+
try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact managed group already exited. */ }
|
|
532
|
+
}
|
|
533
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
534
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
test("guardian recovers a persistent service after its final active release stops", async () => {
|
|
539
|
+
const fixture = await createFixture()
|
|
540
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
541
|
+
const jobs = processes.find((processConfig) => processConfig.id === "jobs")
|
|
542
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
543
|
+
|
|
544
|
+
assert.ok(jobs && worker)
|
|
545
|
+
jobs.port = {from: 17000, to: 17001}
|
|
546
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
547
|
+
fixture.config.processes = processes.filter((processConfig) => processConfig.id !== "singleton")
|
|
548
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
549
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
550
|
+
let recoveredDaemonPid
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
await waitForLog(owner, "control socket listening")
|
|
554
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
555
|
+
|
|
556
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
|
|
557
|
+
await sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath})
|
|
558
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
559
|
+
const servicePid = before.services[0]?.process.pid
|
|
560
|
+
|
|
561
|
+
assert.equal(before.activeReleaseId, null)
|
|
562
|
+
assert.equal(typeof servicePid, "number")
|
|
563
|
+
const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
|
|
564
|
+
|
|
565
|
+
assert.equal(persisted.serviceReleaseIds?.beacon, "v1")
|
|
566
|
+
const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
|
|
567
|
+
|
|
568
|
+
owner.kill("SIGKILL")
|
|
569
|
+
await once(owner, "exit")
|
|
570
|
+
await recoveredListenerLog
|
|
571
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
572
|
+
|
|
573
|
+
recoveredDaemonPid = recovered.daemonPid
|
|
574
|
+
assert.equal(recovered.activeReleaseId, null)
|
|
575
|
+
assert.equal(recovered.services[0]?.process.pid, servicePid)
|
|
576
|
+
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
577
|
+
const nextReleasePath = await prepareRelease(fixture.root, "v2")
|
|
578
|
+
|
|
579
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: nextReleasePath, revision: "v2"}, path: fixture.socketPath})
|
|
580
|
+
const finalReleasePath = await prepareRelease(fixture.root, "v3")
|
|
581
|
+
|
|
582
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: finalReleasePath, revision: "v3"}, path: fixture.socketPath})
|
|
583
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v3")
|
|
584
|
+
await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
585
|
+
} finally {
|
|
586
|
+
await killChild(owner)
|
|
587
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
588
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
589
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test("owner state omits a new persistent service until its defining release is retained", async () => {
|
|
594
|
+
const fixture = await createFixture()
|
|
595
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
596
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
597
|
+
const releasePath = await prepareRelease(fixture.root, "v1", {holdJobsBind: true})
|
|
598
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
599
|
+
let deploy
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
await owner.start()
|
|
603
|
+
deploy = owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
604
|
+
await waitForFile(path.join(releasePath, "jobs.bind-waiting"), 3000)
|
|
605
|
+
await owner.persistState({throwOnError: true})
|
|
606
|
+
const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
|
|
607
|
+
const guardianState = /** @type {Record<string, import("../src/json.js").JsonValue> | undefined} */ (await owner.guardian?.ownerState())
|
|
608
|
+
const guardianSnapshot = /** @type {RecoveryState | undefined} */ (guardianState?.snapshot)
|
|
609
|
+
|
|
610
|
+
assert.deepEqual(persisted.releases, [])
|
|
611
|
+
assert.deepEqual(persisted.services, [])
|
|
612
|
+
assert.deepEqual(persisted.serviceReleaseIds, {})
|
|
613
|
+
assert.deepEqual(guardianSnapshot?.releases, [])
|
|
614
|
+
assert.deepEqual(guardianSnapshot?.services, [])
|
|
615
|
+
assert.deepEqual(guardianState?.serviceReleaseIds, {})
|
|
616
|
+
|
|
617
|
+
await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n")
|
|
618
|
+
await deploy
|
|
619
|
+
assert.equal(owner.status().services.find(({id}) => id === "beacon")?.process.state, "running")
|
|
620
|
+
assert.equal(owner.serviceReleaseIds.get("beacon"), "v1")
|
|
621
|
+
} finally {
|
|
622
|
+
await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n").catch(() => {})
|
|
623
|
+
await deploy?.catch(() => {})
|
|
624
|
+
await Promise.all([
|
|
625
|
+
fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
|
|
626
|
+
owner.shutdown().catch(() => {})
|
|
627
|
+
])
|
|
628
|
+
owner.guardian?.disconnect()
|
|
629
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
630
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
test("owner recovery accepts released format-2 state without journal revision or service owner metadata", async () => {
|
|
635
|
+
const fixture = await createFixture()
|
|
636
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
637
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
638
|
+
|
|
639
|
+
assert.ok(worker)
|
|
640
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
641
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
642
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
643
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
644
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
645
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
646
|
+
let recovered
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
await owner.start()
|
|
650
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
651
|
+
if (owner.pendingWrite) await owner.pendingWrite
|
|
652
|
+
const publicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
653
|
+
const privateOwnerState = owner.transferableOwnerState()
|
|
654
|
+
const privateSnapshot = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateOwnerState.snapshot)
|
|
655
|
+
const privateTransition = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateSnapshot.generationTransition)
|
|
656
|
+
|
|
657
|
+
delete publicState.generationTransition.journalRevision
|
|
658
|
+
delete publicState.serviceReleaseIds
|
|
659
|
+
delete privateTransition.journalRevision
|
|
660
|
+
delete privateOwnerState.serviceReleaseIds
|
|
661
|
+
await owner.guardian?.publishOwnerState(privateOwnerState)
|
|
662
|
+
await fs.writeFile(fixture.statePath, `${JSON.stringify(publicState, null, 2)}\n`)
|
|
663
|
+
await owner.retireCommittedOwner(undefined)
|
|
664
|
+
owner.guardian?.disconnect()
|
|
665
|
+
|
|
666
|
+
recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
667
|
+
await recovered.start()
|
|
668
|
+
|
|
669
|
+
assert.equal(recovered.status().activeReleaseId, "v1")
|
|
670
|
+
assert.equal(recovered.status().generationTransition?.journalRevision, undefined)
|
|
671
|
+
assert.equal(recovered.serviceReleaseIds.get("beacon"), "v1")
|
|
672
|
+
} finally {
|
|
673
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
674
|
+
else await owner.shutdown().catch(() => {})
|
|
675
|
+
owner.guardian?.disconnect()
|
|
676
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
677
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
test("guardian recovery becomes ready before replaying a gated generation hook", async () => {
|
|
682
|
+
const fixture = await createFixture()
|
|
683
|
+
const retirementGatePath = path.join(fixture.root, "retirement.allow")
|
|
684
|
+
const retirementWaitingPath = path.join(fixture.root, "retirement.waiting")
|
|
685
|
+
const daemonPidPath = path.join(fixture.root, "daemon.pid")
|
|
686
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
687
|
+
const jobs = processes.find((processConfig) => processConfig.id === "jobs")
|
|
688
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
689
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
|
|
690
|
+
|
|
691
|
+
assert.ok(jobs && worker)
|
|
692
|
+
jobs.gracefulStopMs = 5000
|
|
693
|
+
lifecycle.quietCommand = `printf 'waiting\n' >> ${JSON.stringify(retirementWaitingPath)}; while [ ! -f ${JSON.stringify(retirementGatePath)} ]; do sleep 0.02; done; printf 'retire:%s\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(fixture.lifecycleLogPath)}`
|
|
694
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
695
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
696
|
+
const owner = spawnDaemon(fixture.configPath, undefined, {daemonPidPath, startupTimeoutMs: 3000})
|
|
697
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
698
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
699
|
+
let recoveredDaemonPid
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
await waitForLog(owner, "control socket listening")
|
|
703
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
704
|
+
const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
705
|
+
|
|
706
|
+
void interruptedDeploy.catch(() => {})
|
|
707
|
+
await waitForFile(retirementWaitingPath)
|
|
708
|
+
await fs.rm(daemonPidPath, {force: true})
|
|
709
|
+
const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
|
|
710
|
+
|
|
711
|
+
owner.kill("SIGKILL")
|
|
712
|
+
await once(owner, "exit")
|
|
713
|
+
await recoveredListenerLog
|
|
714
|
+
await waitForFile(daemonPidPath, 3000)
|
|
715
|
+
recoveredDaemonPid = Number((await fs.readFile(daemonPidPath, "utf8")).trim())
|
|
716
|
+
const recovering = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
717
|
+
const guardianPid = JSON.parse(await fs.readFile(fixture.statePath, "utf8")).recovery?.guardian?.pid
|
|
718
|
+
|
|
719
|
+
assert.equal(recovering.daemonPid, recoveredDaemonPid)
|
|
720
|
+
assert.equal(recovering.ownerRecovery?.ready, true)
|
|
721
|
+
assert.equal(recovering.generationTransition?.phase, "retiring_previous")
|
|
722
|
+
assert.equal(typeof guardianPid, "number")
|
|
723
|
+
await assert.rejects(
|
|
724
|
+
sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath}),
|
|
725
|
+
/Another owner mutation/
|
|
726
|
+
)
|
|
727
|
+
await assert.rejects(
|
|
728
|
+
sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}),
|
|
729
|
+
/Cannot shut down while generation transition recovery is in progress/
|
|
730
|
+
)
|
|
731
|
+
await assert.rejects(
|
|
732
|
+
sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: fixture.socketPath}),
|
|
733
|
+
/Cannot retire owner while generation transition recovery is in progress/
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
process.kill(recoveredDaemonPid, "SIGTERM")
|
|
737
|
+
await fs.writeFile(retirementGatePath, "release retirement\n")
|
|
738
|
+
await waitForProcessExit(recoveredDaemonPid, 5000)
|
|
739
|
+
await waitForProcessExit(guardianPid, 5000)
|
|
740
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2", "retire:v2"])
|
|
741
|
+
} finally {
|
|
742
|
+
await fs.writeFile(retirementGatePath, "release retirement\n").catch(() => undefined)
|
|
743
|
+
await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}).catch(() => undefined)
|
|
744
|
+
await killChild(owner)
|
|
745
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
746
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
747
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
748
|
+
}
|
|
749
|
+
})
|
|
750
|
+
|
|
428
751
|
test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
|
|
429
752
|
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
430
753
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -483,10 +806,15 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
483
806
|
const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
484
807
|
|
|
485
808
|
delete ambiguous.generationTransition.error
|
|
809
|
+
ambiguous.generationTransition.journalRevision += 1
|
|
486
810
|
await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
|
|
487
811
|
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
488
812
|
owner = spawnDaemon(fixture.configPath)
|
|
813
|
+
const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
|
|
814
|
+
|
|
489
815
|
await waitForLog(owner, "control socket listening")
|
|
816
|
+
await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
|
|
817
|
+
await recoverySettled
|
|
490
818
|
|
|
491
819
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
492
820
|
|
|
@@ -504,8 +832,14 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
504
832
|
}
|
|
505
833
|
})
|
|
506
834
|
|
|
507
|
-
test("owner recovery preserves
|
|
835
|
+
test("owner recovery preserves complete private transition authority across a candidate config change", async () => {
|
|
508
836
|
const fixture = await createFixture()
|
|
837
|
+
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
838
|
+
const initialWorker = initialProcesses.find((processConfig) => processConfig.id === "worker")
|
|
839
|
+
|
|
840
|
+
assert.ok(initialWorker)
|
|
841
|
+
initialWorker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
842
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
509
843
|
const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
|
|
510
844
|
const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
|
|
511
845
|
/** @type {RollbridgeDaemon | undefined} */
|
|
@@ -534,6 +868,12 @@ test("owner recovery preserves exact release definitions across a candidate conf
|
|
|
534
868
|
|
|
535
869
|
await owner.retireCommittedOwner(undefined)
|
|
536
870
|
owner.guardian?.disconnect()
|
|
871
|
+
const partialPublicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
872
|
+
|
|
873
|
+
partialPublicState.generationTransition.phase = "committed"
|
|
874
|
+
partialPublicState.releases = partialPublicState.releases.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
|
|
875
|
+
partialPublicState.releaseReferences = partialPublicState.releaseReferences.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
|
|
876
|
+
await fs.writeFile(fixture.statePath, `${JSON.stringify(partialPublicState, null, 2)}\n`)
|
|
537
877
|
|
|
538
878
|
recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
|
|
539
879
|
await recovered.start()
|
|
@@ -542,12 +882,7 @@ test("owner recovery preserves exact release definitions across a candidate conf
|
|
|
542
882
|
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
543
883
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
544
884
|
} finally {
|
|
545
|
-
if (recovered) {
|
|
546
|
-
const shutdown = recovered.shutdown()
|
|
547
|
-
|
|
548
|
-
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
|
|
549
|
-
await shutdown.catch(() => {})
|
|
550
|
-
}
|
|
885
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
551
886
|
await stopFixtureGuardian(fixture.statePath)
|
|
552
887
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
553
888
|
}
|
|
@@ -583,6 +918,7 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
|
|
|
583
918
|
|
|
584
919
|
assert.equal(pending.activeReleaseId, "v2")
|
|
585
920
|
assert.equal(pending.generationTransition?.phase, "committed_pending")
|
|
921
|
+
assert.equal(pending.serviceReleaseIds?.beacon, "v2")
|
|
586
922
|
assert.equal(pending.singletonReleaseIds?.singleton, "v1")
|
|
587
923
|
const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
|
|
588
924
|
|
|
@@ -622,6 +958,9 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
|
|
|
622
958
|
|
|
623
959
|
test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
|
|
624
960
|
const fixture = await createFixture()
|
|
961
|
+
const fixtureProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
962
|
+
|
|
963
|
+
fixture.config.processes = fixtureProcesses.filter((processConfig) => processConfig.id !== "beacon")
|
|
625
964
|
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
626
965
|
const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
|
|
627
966
|
|
|
@@ -688,6 +1027,67 @@ test("owner recovery uses the owning release singleton definition during a commi
|
|
|
688
1027
|
}
|
|
689
1028
|
})
|
|
690
1029
|
|
|
1030
|
+
test("owner recovery retains a stopped previous release until committed-pending singleton work completes", async () => {
|
|
1031
|
+
const fixture = await createFixture()
|
|
1032
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
1033
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
1034
|
+
|
|
1035
|
+
assert.ok(worker)
|
|
1036
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
1037
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1038
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
1039
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1040
|
+
const replaceSingletons = owner.replaceSingletons.bind(owner)
|
|
1041
|
+
/** @type {() => void} */
|
|
1042
|
+
let continueReplacement = () => {}
|
|
1043
|
+
/** @type {() => void} */
|
|
1044
|
+
let markReplacementComplete = () => {}
|
|
1045
|
+
const replacementGate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
|
|
1046
|
+
const replacementComplete = new Promise((resolve) => { markReplacementComplete = () => resolve(undefined) })
|
|
1047
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1048
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
1049
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
1050
|
+
let deployPromise
|
|
1051
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
1052
|
+
let recovered
|
|
1053
|
+
|
|
1054
|
+
owner.replaceSingletons = async (release) => {
|
|
1055
|
+
await replaceSingletons(release)
|
|
1056
|
+
if (release.releaseId === "v2") {
|
|
1057
|
+
markReplacementComplete()
|
|
1058
|
+
await replacementGate
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
await owner.start()
|
|
1064
|
+
await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
1065
|
+
deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
1066
|
+
void deployPromise.catch(() => {})
|
|
1067
|
+
await replacementComplete
|
|
1068
|
+
const pending = await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed_pending" && state.releases?.some((release) => release.releaseId === "v1" && release.state === "stopped"), AbortSignal.timeout(5000))
|
|
1069
|
+
|
|
1070
|
+
assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
|
|
1071
|
+
assert.equal(pending.singletonReleaseIds?.singleton, "v2")
|
|
1072
|
+
await owner.retireCommittedOwner(undefined)
|
|
1073
|
+
owner.guardian?.disconnect()
|
|
1074
|
+
|
|
1075
|
+
recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1076
|
+
await recovered.start()
|
|
1077
|
+
|
|
1078
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
1079
|
+
assert.equal(recovered.status().activeReleaseId, "v2")
|
|
1080
|
+
} finally {
|
|
1081
|
+
continueReplacement()
|
|
1082
|
+
await deployPromise?.catch(() => {})
|
|
1083
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
1084
|
+
else await owner.shutdown().catch(() => {})
|
|
1085
|
+
owner.guardian?.disconnect()
|
|
1086
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1087
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1088
|
+
}
|
|
1089
|
+
})
|
|
1090
|
+
|
|
691
1091
|
test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
|
|
692
1092
|
const fixture = await createFixture()
|
|
693
1093
|
const retirementGatePath = path.join(fixture.root, "retirement.allow")
|
|
@@ -722,8 +1122,11 @@ test("owner recovery replays ambiguous retirement with the previous release's ex
|
|
|
722
1122
|
await fs.writeFile(retirementGatePath, "release retirement\n")
|
|
723
1123
|
|
|
724
1124
|
owner = spawnDaemon(fixture.configPath)
|
|
1125
|
+
const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
|
|
725
1126
|
|
|
726
1127
|
await waitForLog(owner, "control socket listening")
|
|
1128
|
+
await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
|
|
1129
|
+
await recoverySettled
|
|
727
1130
|
assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
|
|
728
1131
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
|
|
729
1132
|
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
|
|
@@ -760,7 +1163,7 @@ test("owner recovery rejects config identity mismatch without changing the valid
|
|
|
760
1163
|
const rejected = await runDaemon(fixture.configPath)
|
|
761
1164
|
|
|
762
1165
|
assert.notEqual(rejected.code, 0)
|
|
763
|
-
assert.match(rejected.output, /
|
|
1166
|
+
assert.match(rejected.output, /authority does not match/)
|
|
764
1167
|
assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
|
|
765
1168
|
|
|
766
1169
|
await writeConfig(fixture.configPath, fixture.config)
|
|
@@ -819,9 +1222,10 @@ test("failed recovery bootstrap keeps the reconstructed active generation servin
|
|
|
819
1222
|
}
|
|
820
1223
|
})
|
|
821
1224
|
|
|
822
|
-
test("owner recovery
|
|
1225
|
+
test("owner recovery repairs a partial public snapshot from committed guardian state", async () => {
|
|
823
1226
|
const fixture = await createFixture()
|
|
824
|
-
|
|
1227
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
1228
|
+
let recoveredDaemonPid
|
|
825
1229
|
let workerPid
|
|
826
1230
|
|
|
827
1231
|
try {
|
|
@@ -838,21 +1242,24 @@ test("owner recovery fails closed on a partial snapshot and preserves it for rep
|
|
|
838
1242
|
const partialState = {...validState, releases: []}
|
|
839
1243
|
await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
|
|
840
1244
|
|
|
841
|
-
const
|
|
1245
|
+
const repairedState = await waitForState(
|
|
1246
|
+
fixture.statePath,
|
|
1247
|
+
(state) => state.daemonPid !== validState.daemonPid && state.releases.some((release) => release.releaseId === "v1"),
|
|
1248
|
+
AbortSignal.timeout(5000)
|
|
1249
|
+
)
|
|
1250
|
+
recoveredDaemonPid = repairedState.daemonPid
|
|
1251
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
842
1252
|
|
|
843
|
-
assert.
|
|
844
|
-
assert.
|
|
845
|
-
assert.
|
|
1253
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
1254
|
+
assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
|
|
1255
|
+
assert.notDeepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
|
|
846
1256
|
|
|
847
|
-
await fs.writeFile(fixture.statePath, `${JSON.stringify(validState, null, 2)}\n`)
|
|
848
|
-
owner = spawnDaemon(fixture.configPath)
|
|
849
|
-
await waitForLog(owner, "control socket listening")
|
|
850
1257
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
851
1258
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
852
1259
|
await shutdown
|
|
853
|
-
await once(owner, "exit")
|
|
854
1260
|
} finally {
|
|
855
1261
|
await killChild(owner)
|
|
1262
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
856
1263
|
if (workerPid) {
|
|
857
1264
|
try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
|
|
858
1265
|
}
|
|
@@ -1014,6 +1421,75 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
|
|
|
1014
1421
|
}
|
|
1015
1422
|
})
|
|
1016
1423
|
|
|
1424
|
+
test("deploy rejects a live activation lifecycle mode change", async () => {
|
|
1425
|
+
const fixture = await createFixture()
|
|
1426
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
1427
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1428
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
await waitForLog(owner, "control socket listening")
|
|
1432
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
1433
|
+
const changedConfig = structuredClone(fixture.config)
|
|
1434
|
+
const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes).find((processConfig) => processConfig.id === "jobs")
|
|
1435
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
|
|
1436
|
+
|
|
1437
|
+
delete lifecycle.activateCommand
|
|
1438
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
1439
|
+
await assert.rejects(
|
|
1440
|
+
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
1441
|
+
/lifecycle\.activateCommand.*cannot be applied live/
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1445
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
1446
|
+
|
|
1447
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1448
|
+
await shutdown
|
|
1449
|
+
} finally {
|
|
1450
|
+
await killChild(owner)
|
|
1451
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1452
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1453
|
+
}
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
test("public state does not advance when private guardian publication fails", async () => {
|
|
1457
|
+
const fixture = await createFixture()
|
|
1458
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
1459
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
1460
|
+
|
|
1461
|
+
assert.ok(worker)
|
|
1462
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
1463
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1464
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
1465
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1466
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
1467
|
+
|
|
1468
|
+
try {
|
|
1469
|
+
await owner.start()
|
|
1470
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
1471
|
+
if (owner.pendingWrite) await owner.pendingWrite
|
|
1472
|
+
const before = await fs.readFile(fixture.statePath, "utf8")
|
|
1473
|
+
const publishOwnerState = owner.publishOwnerState.bind(owner)
|
|
1474
|
+
|
|
1475
|
+
owner.ownerReady = true
|
|
1476
|
+
owner.publishOwnerState = async () => { throw new Error("injected guardian publication failure") }
|
|
1477
|
+
const write = owner.persistState({throwOnError: true})
|
|
1478
|
+
|
|
1479
|
+
assert.ok(write)
|
|
1480
|
+
await assert.rejects(write, /injected guardian publication failure/)
|
|
1481
|
+
assert.equal(await fs.readFile(fixture.statePath, "utf8"), before)
|
|
1482
|
+
owner.publishOwnerState = publishOwnerState
|
|
1483
|
+
await owner.persistState({throwOnError: true})
|
|
1484
|
+
await owner.shutdown()
|
|
1485
|
+
} finally {
|
|
1486
|
+
owner.publishOwnerState = RollbridgeDaemon.prototype.publishOwnerState.bind(owner)
|
|
1487
|
+
await owner.shutdown().catch(() => {})
|
|
1488
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1489
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1490
|
+
}
|
|
1491
|
+
})
|
|
1492
|
+
|
|
1017
1493
|
test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
|
|
1018
1494
|
const fixture = await createFixture()
|
|
1019
1495
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -1056,7 +1532,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
1056
1532
|
|
|
1057
1533
|
assert.equal(recovered.activeReleaseId, "v1")
|
|
1058
1534
|
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
|
|
1059
|
-
assert.equal(
|
|
1535
|
+
assert.equal(isProcessRunning(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
|
|
1060
1536
|
|
|
1061
1537
|
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
1062
1538
|
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2", "removed candidate keys must be reusable by a later valid deploy")
|
|
@@ -1067,7 +1543,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
1067
1543
|
await once(owner, "exit")
|
|
1068
1544
|
} finally {
|
|
1069
1545
|
await killChild(owner)
|
|
1070
|
-
if (candidatePid &&
|
|
1546
|
+
if (candidatePid && isProcessRunning(candidatePid)) {
|
|
1071
1547
|
try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
|
|
1072
1548
|
}
|
|
1073
1549
|
await stopFixtureGuardian(fixture.statePath)
|
|
@@ -1075,6 +1551,47 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
1075
1551
|
}
|
|
1076
1552
|
})
|
|
1077
1553
|
|
|
1554
|
+
test("ensure-daemon replaces a same-authority owner whose control socket disappeared", async () => {
|
|
1555
|
+
const fixture = await createFixture()
|
|
1556
|
+
const runtimePath = path.join(fixture.root, "runtime")
|
|
1557
|
+
const daemonLogPath = path.join(fixture.root, "same-authority-replacement.log")
|
|
1558
|
+
const daemonPidPath = path.join(fixture.root, "same-authority-replacement.pid")
|
|
1559
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1560
|
+
const ensureArgs = [
|
|
1561
|
+
"ensure-daemon", "--config", fixture.configPath,
|
|
1562
|
+
"--daemon-log-path", daemonLogPath,
|
|
1563
|
+
"--daemon-pid-path", daemonPidPath,
|
|
1564
|
+
"--daemon-runtime-path", runtimePath,
|
|
1565
|
+
"--daemon-start-timeout-ms", "5000"
|
|
1566
|
+
]
|
|
1567
|
+
|
|
1568
|
+
try {
|
|
1569
|
+
const first = await runCli(ensureArgs)
|
|
1570
|
+
|
|
1571
|
+
assert.equal(first.code, 0, first.output)
|
|
1572
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
1573
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
1574
|
+
|
|
1575
|
+
await fs.rm(fixture.socketPath, {force: true})
|
|
1576
|
+
const replacement = await runCli(ensureArgs)
|
|
1577
|
+
|
|
1578
|
+
assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
|
|
1579
|
+
const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
1580
|
+
|
|
1581
|
+
assert.equal(after.activeReleaseId, "v1")
|
|
1582
|
+
assert.notEqual(after.daemonPid, before.daemonPid)
|
|
1583
|
+
await waitForProcessExit(before.daemonPid)
|
|
1584
|
+
|
|
1585
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
1586
|
+
|
|
1587
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1588
|
+
await shutdown
|
|
1589
|
+
} finally {
|
|
1590
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1591
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1592
|
+
}
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1078
1595
|
test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
|
|
1079
1596
|
const fixture = await createFixture()
|
|
1080
1597
|
const oldControlPath = fixture.socketPath
|
|
@@ -1137,6 +1654,16 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
|
|
|
1137
1654
|
assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
|
|
1138
1655
|
assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
|
|
1139
1656
|
assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
|
|
1657
|
+
assert.ok(after.daemonPid)
|
|
1658
|
+
await fs.writeFile(daemonLogPath, "")
|
|
1659
|
+
process.kill(after.daemonPid, "SIGKILL")
|
|
1660
|
+
const restartedState = await waitForState(fixture.statePath, (state) => state.daemonPid !== after.daemonPid, AbortSignal.timeout(5000))
|
|
1661
|
+
const restarted = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
|
|
1662
|
+
|
|
1663
|
+
assert.equal(restarted.daemonPid, restartedState.daemonPid)
|
|
1664
|
+
assert.equal(Number((await fs.readFile(daemonPidPath, "utf8")).trim()), restarted.daemonPid)
|
|
1665
|
+
assert.deepEqual(restarted.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
|
|
1666
|
+
assert.match(await fs.readFile(daemonLogPath, "utf8"), /owner state recovered/)
|
|
1140
1667
|
|
|
1141
1668
|
const v3Path = await prepareRelease(fixture.root, "v3")
|
|
1142
1669
|
await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
|
|
@@ -1380,10 +1907,11 @@ async function stopFixtureGuardian(statePath) {
|
|
|
1380
1907
|
/**
|
|
1381
1908
|
* @param {string} statePath - State path.
|
|
1382
1909
|
* @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
|
|
1910
|
+
* @param {AbortSignal} [signal] - Optional deadline signal.
|
|
1383
1911
|
* @returns {Promise<RecoveryState>} Matching state.
|
|
1384
1912
|
*/
|
|
1385
|
-
async function waitForState(statePath, predicate) {
|
|
1386
|
-
const watcher = fs.watch(path.dirname(statePath))
|
|
1913
|
+
async function waitForState(statePath, predicate, signal) {
|
|
1914
|
+
const watcher = fs.watch(path.dirname(statePath), {signal})
|
|
1387
1915
|
|
|
1388
1916
|
try {
|
|
1389
1917
|
const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
|
|
@@ -1437,17 +1965,6 @@ async function waitForFile(filePath, timeoutMs) {
|
|
|
1437
1965
|
}
|
|
1438
1966
|
}
|
|
1439
1967
|
|
|
1440
|
-
/**
|
|
1441
|
-
* @param {number} pid - Exact fixture process.
|
|
1442
|
-
* @param {number} timeoutMs - Bounded exit wait.
|
|
1443
|
-
*/
|
|
1444
|
-
async function waitForProcessExit(pid, timeoutMs) {
|
|
1445
|
-
const deadline = Date.now() + timeoutMs
|
|
1446
|
-
|
|
1447
|
-
while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
1448
|
-
assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
1968
|
/**
|
|
1452
1969
|
* Opens a live WebSocket through the fixture proxy.
|
|
1453
1970
|
* @param {number} port - Proxy port.
|
|
@@ -1485,29 +2002,18 @@ function releaseProcessPid(status, releaseId, processId) {
|
|
|
1485
2002
|
return pid
|
|
1486
2003
|
}
|
|
1487
2004
|
|
|
1488
|
-
/**
|
|
1489
|
-
* @param {number} pid - Exact fixture pid.
|
|
1490
|
-
* @returns {boolean} Whether the exact fixture process is alive.
|
|
1491
|
-
*/
|
|
1492
|
-
function isAlive(pid) {
|
|
1493
|
-
try {
|
|
1494
|
-
process.kill(pid, 0)
|
|
1495
|
-
return true
|
|
1496
|
-
} catch (error) {
|
|
1497
|
-
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
|
|
1498
|
-
throw error
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
2005
|
/**
|
|
1503
2006
|
* @param {string} configPath - Config path.
|
|
1504
2007
|
* @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
|
|
2008
|
+
* @param {{daemonPidPath?: string, startupTimeoutMs?: number}} [options] - Recovery command options.
|
|
1505
2009
|
* @returns {import("node:child_process").ChildProcess} Daemon process.
|
|
1506
2010
|
*/
|
|
1507
|
-
function spawnDaemon(configPath, bootstrap) {
|
|
2011
|
+
function spawnDaemon(configPath, bootstrap, options = {}) {
|
|
1508
2012
|
const args = [binPath, "daemon", "--config", configPath]
|
|
1509
2013
|
|
|
1510
2014
|
if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
|
|
2015
|
+
if (options.daemonPidPath) args.push("--guardian-daemon-pid-path", options.daemonPidPath)
|
|
2016
|
+
if (options.startupTimeoutMs) args.push("--guardian-daemon-start-timeout-ms", String(options.startupTimeoutMs))
|
|
1511
2017
|
return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
|
|
1512
2018
|
}
|
|
1513
2019
|
|
|
@@ -1554,14 +2060,16 @@ async function runCli(args) {
|
|
|
1554
2060
|
/**
|
|
1555
2061
|
* @param {import("node:child_process").ChildProcess} child - Daemon child.
|
|
1556
2062
|
* @param {string} message - Structured log message.
|
|
2063
|
+
* @param {{allowChildExit?: boolean}} [options] - Whether inherited descriptors may outlive the original child.
|
|
1557
2064
|
*/
|
|
1558
|
-
async function waitForLog(child, message) {
|
|
2065
|
+
async function waitForLog(child, message, {allowChildExit = false} = {}) {
|
|
1559
2066
|
assert.ok(child.stdout)
|
|
1560
2067
|
child.stdout.setEncoding("utf8")
|
|
1561
2068
|
|
|
1562
2069
|
await new Promise((resolve, reject) => {
|
|
1563
2070
|
let buffer = ""
|
|
1564
2071
|
let stderr = ""
|
|
2072
|
+
const timer = setTimeout(() => finish(new Error(`Timed out waiting for daemon log ${message}: ${stderr.trim()}`)), 5000)
|
|
1565
2073
|
const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
|
|
1566
2074
|
const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
|
|
1567
2075
|
/** @param {string} chunk - Output chunk. */
|
|
@@ -1579,6 +2087,7 @@ async function waitForLog(child, message) {
|
|
|
1579
2087
|
}
|
|
1580
2088
|
/** @param {Error} [error] - Failure. */
|
|
1581
2089
|
const finish = (error) => {
|
|
2090
|
+
clearTimeout(timer)
|
|
1582
2091
|
child.off("exit", onExit)
|
|
1583
2092
|
child.stdout?.off("data", onData)
|
|
1584
2093
|
child.stderr?.off("data", onErrorData)
|
|
@@ -1586,7 +2095,7 @@ async function waitForLog(child, message) {
|
|
|
1586
2095
|
else resolve(undefined)
|
|
1587
2096
|
}
|
|
1588
2097
|
|
|
1589
|
-
child.once("exit", onExit)
|
|
2098
|
+
if (!allowChildExit) child.once("exit", onExit)
|
|
1590
2099
|
child.stdout?.on("data", onData)
|
|
1591
2100
|
child.stderr?.setEncoding("utf8").on("data", onErrorData)
|
|
1592
2101
|
})
|