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
|
@@ -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,7 +494,261 @@ test("replacement owner reconstructs one active and two draining generations aft
|
|
|
425
494
|
}
|
|
426
495
|
})
|
|
427
496
|
|
|
428
|
-
test("
|
|
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
|
+
|
|
751
|
+
test("owner recovery preserves a completed activation compensation without replaying hooks", async () => {
|
|
429
752
|
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
430
753
|
let owner = spawnDaemon(fixture.configPath)
|
|
431
754
|
|
|
@@ -439,7 +762,7 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
439
762
|
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
440
763
|
/activate command exited non-zero/
|
|
441
764
|
)
|
|
442
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
|
|
765
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
443
766
|
|
|
444
767
|
owner.kill("SIGKILL")
|
|
445
768
|
await once(owner, "exit")
|
|
@@ -448,13 +771,9 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
448
771
|
|
|
449
772
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
450
773
|
|
|
451
|
-
assert.equal(recovered.
|
|
452
|
-
assert.
|
|
453
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery
|
|
454
|
-
|
|
455
|
-
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
456
|
-
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
457
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
774
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
775
|
+
assert.equal(recovered.generationTransition, undefined)
|
|
776
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"], "owner recovery must not replay completed compensation hooks")
|
|
458
777
|
|
|
459
778
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
460
779
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -466,36 +785,38 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
466
785
|
}
|
|
467
786
|
})
|
|
468
787
|
|
|
469
|
-
test("owner recovery replays one journaled ambiguous activation by exact
|
|
470
|
-
const fixture = await createFixture({activationFailureRelease: "
|
|
788
|
+
test("owner recovery replays one journaled ambiguous first-generation activation by exact identity", async () => {
|
|
789
|
+
const fixture = await createFixture({activationFailureRelease: "v1"})
|
|
471
790
|
let owner = spawnDaemon(fixture.configPath)
|
|
472
791
|
|
|
473
792
|
try {
|
|
474
793
|
await waitForLog(owner, "control socket listening")
|
|
475
794
|
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
479
|
-
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
|
|
795
|
+
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath}))
|
|
480
796
|
owner.kill("SIGKILL")
|
|
481
797
|
await once(owner, "exit")
|
|
482
798
|
|
|
483
799
|
const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
484
800
|
|
|
485
801
|
delete ambiguous.generationTransition.error
|
|
802
|
+
ambiguous.generationTransition.journalRevision += 1
|
|
486
803
|
await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
|
|
487
804
|
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
488
805
|
owner = spawnDaemon(fixture.configPath)
|
|
806
|
+
const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
|
|
807
|
+
|
|
489
808
|
await waitForLog(owner, "control socket listening")
|
|
809
|
+
await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
|
|
810
|
+
await recoverySettled
|
|
490
811
|
|
|
491
812
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
492
813
|
|
|
493
|
-
assert.equal(recovered.activeReleaseId, "
|
|
814
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
494
815
|
assert.equal(recovered.generationTransition?.phase, "committed")
|
|
495
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"
|
|
816
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
496
817
|
|
|
497
818
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
498
|
-
await
|
|
819
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
499
820
|
await shutdown
|
|
500
821
|
} finally {
|
|
501
822
|
await killChild(owner)
|
|
@@ -504,8 +825,14 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
504
825
|
}
|
|
505
826
|
})
|
|
506
827
|
|
|
507
|
-
test("owner recovery preserves
|
|
828
|
+
test("owner recovery preserves complete private transition authority across a candidate config change", async () => {
|
|
508
829
|
const fixture = await createFixture()
|
|
830
|
+
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
831
|
+
const initialWorker = initialProcesses.find((processConfig) => processConfig.id === "worker")
|
|
832
|
+
|
|
833
|
+
assert.ok(initialWorker)
|
|
834
|
+
initialWorker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
835
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
509
836
|
const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
|
|
510
837
|
const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
|
|
511
838
|
/** @type {RollbridgeDaemon | undefined} */
|
|
@@ -534,6 +861,12 @@ test("owner recovery preserves exact release definitions across a candidate conf
|
|
|
534
861
|
|
|
535
862
|
await owner.retireCommittedOwner(undefined)
|
|
536
863
|
owner.guardian?.disconnect()
|
|
864
|
+
const partialPublicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
865
|
+
|
|
866
|
+
partialPublicState.generationTransition.phase = "committed"
|
|
867
|
+
partialPublicState.releases = partialPublicState.releases.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
|
|
868
|
+
partialPublicState.releaseReferences = partialPublicState.releaseReferences.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
|
|
869
|
+
await fs.writeFile(fixture.statePath, `${JSON.stringify(partialPublicState, null, 2)}\n`)
|
|
537
870
|
|
|
538
871
|
recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
|
|
539
872
|
await recovered.start()
|
|
@@ -542,12 +875,7 @@ test("owner recovery preserves exact release definitions across a candidate conf
|
|
|
542
875
|
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
543
876
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
544
877
|
} 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
|
-
}
|
|
878
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
551
879
|
await stopFixtureGuardian(fixture.statePath)
|
|
552
880
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
553
881
|
}
|
|
@@ -583,6 +911,7 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
|
|
|
583
911
|
|
|
584
912
|
assert.equal(pending.activeReleaseId, "v2")
|
|
585
913
|
assert.equal(pending.generationTransition?.phase, "committed_pending")
|
|
914
|
+
assert.equal(pending.serviceReleaseIds?.beacon, "v2")
|
|
586
915
|
assert.equal(pending.singletonReleaseIds?.singleton, "v1")
|
|
587
916
|
const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
|
|
588
917
|
|
|
@@ -622,6 +951,9 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
|
|
|
622
951
|
|
|
623
952
|
test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
|
|
624
953
|
const fixture = await createFixture()
|
|
954
|
+
const fixtureProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
955
|
+
|
|
956
|
+
fixture.config.processes = fixtureProcesses.filter((processConfig) => processConfig.id !== "beacon")
|
|
625
957
|
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
626
958
|
const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
|
|
627
959
|
|
|
@@ -688,6 +1020,67 @@ test("owner recovery uses the owning release singleton definition during a commi
|
|
|
688
1020
|
}
|
|
689
1021
|
})
|
|
690
1022
|
|
|
1023
|
+
test("owner recovery retains a stopped previous release until committed-pending singleton work completes", async () => {
|
|
1024
|
+
const fixture = await createFixture()
|
|
1025
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
1026
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
1027
|
+
|
|
1028
|
+
assert.ok(worker)
|
|
1029
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
1030
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1031
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
1032
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1033
|
+
const replaceSingletons = owner.replaceSingletons.bind(owner)
|
|
1034
|
+
/** @type {() => void} */
|
|
1035
|
+
let continueReplacement = () => {}
|
|
1036
|
+
/** @type {() => void} */
|
|
1037
|
+
let markReplacementComplete = () => {}
|
|
1038
|
+
const replacementGate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
|
|
1039
|
+
const replacementComplete = new Promise((resolve) => { markReplacementComplete = () => resolve(undefined) })
|
|
1040
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1041
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
1042
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
1043
|
+
let deployPromise
|
|
1044
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
1045
|
+
let recovered
|
|
1046
|
+
|
|
1047
|
+
owner.replaceSingletons = async (release) => {
|
|
1048
|
+
await replaceSingletons(release)
|
|
1049
|
+
if (release.releaseId === "v2") {
|
|
1050
|
+
markReplacementComplete()
|
|
1051
|
+
await replacementGate
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
await owner.start()
|
|
1057
|
+
await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
1058
|
+
deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
1059
|
+
void deployPromise.catch(() => {})
|
|
1060
|
+
await replacementComplete
|
|
1061
|
+
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))
|
|
1062
|
+
|
|
1063
|
+
assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
|
|
1064
|
+
assert.equal(pending.singletonReleaseIds?.singleton, "v2")
|
|
1065
|
+
await owner.retireCommittedOwner(undefined)
|
|
1066
|
+
owner.guardian?.disconnect()
|
|
1067
|
+
|
|
1068
|
+
recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1069
|
+
await recovered.start()
|
|
1070
|
+
|
|
1071
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
1072
|
+
assert.equal(recovered.status().activeReleaseId, "v2")
|
|
1073
|
+
} finally {
|
|
1074
|
+
continueReplacement()
|
|
1075
|
+
await deployPromise?.catch(() => {})
|
|
1076
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
1077
|
+
else await owner.shutdown().catch(() => {})
|
|
1078
|
+
owner.guardian?.disconnect()
|
|
1079
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1080
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1081
|
+
}
|
|
1082
|
+
})
|
|
1083
|
+
|
|
691
1084
|
test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
|
|
692
1085
|
const fixture = await createFixture()
|
|
693
1086
|
const retirementGatePath = path.join(fixture.root, "retirement.allow")
|
|
@@ -722,8 +1115,11 @@ test("owner recovery replays ambiguous retirement with the previous release's ex
|
|
|
722
1115
|
await fs.writeFile(retirementGatePath, "release retirement\n")
|
|
723
1116
|
|
|
724
1117
|
owner = spawnDaemon(fixture.configPath)
|
|
1118
|
+
const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
|
|
725
1119
|
|
|
726
1120
|
await waitForLog(owner, "control socket listening")
|
|
1121
|
+
await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
|
|
1122
|
+
await recoverySettled
|
|
727
1123
|
assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
|
|
728
1124
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
|
|
729
1125
|
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
|
|
@@ -760,7 +1156,7 @@ test("owner recovery rejects config identity mismatch without changing the valid
|
|
|
760
1156
|
const rejected = await runDaemon(fixture.configPath)
|
|
761
1157
|
|
|
762
1158
|
assert.notEqual(rejected.code, 0)
|
|
763
|
-
assert.match(rejected.output, /
|
|
1159
|
+
assert.match(rejected.output, /authority does not match/)
|
|
764
1160
|
assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
|
|
765
1161
|
|
|
766
1162
|
await writeConfig(fixture.configPath, fixture.config)
|
|
@@ -819,9 +1215,10 @@ test("failed recovery bootstrap keeps the reconstructed active generation servin
|
|
|
819
1215
|
}
|
|
820
1216
|
})
|
|
821
1217
|
|
|
822
|
-
test("owner recovery
|
|
1218
|
+
test("owner recovery repairs a partial public snapshot from committed guardian state", async () => {
|
|
823
1219
|
const fixture = await createFixture()
|
|
824
|
-
|
|
1220
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
1221
|
+
let recoveredDaemonPid
|
|
825
1222
|
let workerPid
|
|
826
1223
|
|
|
827
1224
|
try {
|
|
@@ -838,21 +1235,24 @@ test("owner recovery fails closed on a partial snapshot and preserves it for rep
|
|
|
838
1235
|
const partialState = {...validState, releases: []}
|
|
839
1236
|
await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
|
|
840
1237
|
|
|
841
|
-
const
|
|
1238
|
+
const repairedState = await waitForState(
|
|
1239
|
+
fixture.statePath,
|
|
1240
|
+
(state) => state.daemonPid !== validState.daemonPid && state.releases.some((release) => release.releaseId === "v1"),
|
|
1241
|
+
AbortSignal.timeout(5000)
|
|
1242
|
+
)
|
|
1243
|
+
recoveredDaemonPid = repairedState.daemonPid
|
|
1244
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
842
1245
|
|
|
843
|
-
assert.
|
|
844
|
-
assert.
|
|
845
|
-
assert.
|
|
1246
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
1247
|
+
assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
|
|
1248
|
+
assert.notDeepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
|
|
846
1249
|
|
|
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
1250
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
851
1251
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
852
1252
|
await shutdown
|
|
853
|
-
await once(owner, "exit")
|
|
854
1253
|
} finally {
|
|
855
1254
|
await killChild(owner)
|
|
1255
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
856
1256
|
if (workerPid) {
|
|
857
1257
|
try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
|
|
858
1258
|
}
|
|
@@ -1014,6 +1414,75 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
|
|
|
1014
1414
|
}
|
|
1015
1415
|
})
|
|
1016
1416
|
|
|
1417
|
+
test("deploy rejects a live activation lifecycle mode change", async () => {
|
|
1418
|
+
const fixture = await createFixture()
|
|
1419
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
1420
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1421
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
1422
|
+
|
|
1423
|
+
try {
|
|
1424
|
+
await waitForLog(owner, "control socket listening")
|
|
1425
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
1426
|
+
const changedConfig = structuredClone(fixture.config)
|
|
1427
|
+
const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes).find((processConfig) => processConfig.id === "jobs")
|
|
1428
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
|
|
1429
|
+
|
|
1430
|
+
delete lifecycle.activateCommand
|
|
1431
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
1432
|
+
await assert.rejects(
|
|
1433
|
+
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
1434
|
+
/lifecycle\.activateCommand.*cannot be applied live/
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1438
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
1439
|
+
|
|
1440
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1441
|
+
await shutdown
|
|
1442
|
+
} finally {
|
|
1443
|
+
await killChild(owner)
|
|
1444
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1445
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1446
|
+
}
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
test("public state does not advance when private guardian publication fails", async () => {
|
|
1450
|
+
const fixture = await createFixture()
|
|
1451
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
1452
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
1453
|
+
|
|
1454
|
+
assert.ok(worker)
|
|
1455
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
1456
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1457
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
1458
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1459
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
1460
|
+
|
|
1461
|
+
try {
|
|
1462
|
+
await owner.start()
|
|
1463
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
1464
|
+
if (owner.pendingWrite) await owner.pendingWrite
|
|
1465
|
+
const before = await fs.readFile(fixture.statePath, "utf8")
|
|
1466
|
+
const publishOwnerState = owner.publishOwnerState.bind(owner)
|
|
1467
|
+
|
|
1468
|
+
owner.ownerReady = true
|
|
1469
|
+
owner.publishOwnerState = async () => { throw new Error("injected guardian publication failure") }
|
|
1470
|
+
const write = owner.persistState({throwOnError: true})
|
|
1471
|
+
|
|
1472
|
+
assert.ok(write)
|
|
1473
|
+
await assert.rejects(write, /injected guardian publication failure/)
|
|
1474
|
+
assert.equal(await fs.readFile(fixture.statePath, "utf8"), before)
|
|
1475
|
+
owner.publishOwnerState = publishOwnerState
|
|
1476
|
+
await owner.persistState({throwOnError: true})
|
|
1477
|
+
await owner.shutdown()
|
|
1478
|
+
} finally {
|
|
1479
|
+
owner.publishOwnerState = RollbridgeDaemon.prototype.publishOwnerState.bind(owner)
|
|
1480
|
+
await owner.shutdown().catch(() => {})
|
|
1481
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1482
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1483
|
+
}
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1017
1486
|
test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
|
|
1018
1487
|
const fixture = await createFixture()
|
|
1019
1488
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -1056,7 +1525,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
1056
1525
|
|
|
1057
1526
|
assert.equal(recovered.activeReleaseId, "v1")
|
|
1058
1527
|
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
|
|
1059
|
-
assert.equal(
|
|
1528
|
+
assert.equal(isProcessRunning(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
|
|
1060
1529
|
|
|
1061
1530
|
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
1062
1531
|
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 +1536,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
1067
1536
|
await once(owner, "exit")
|
|
1068
1537
|
} finally {
|
|
1069
1538
|
await killChild(owner)
|
|
1070
|
-
if (candidatePid &&
|
|
1539
|
+
if (candidatePid && isProcessRunning(candidatePid)) {
|
|
1071
1540
|
try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
|
|
1072
1541
|
}
|
|
1073
1542
|
await stopFixtureGuardian(fixture.statePath)
|
|
@@ -1075,6 +1544,47 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
1075
1544
|
}
|
|
1076
1545
|
})
|
|
1077
1546
|
|
|
1547
|
+
test("ensure-daemon replaces a same-authority owner whose control socket disappeared", async () => {
|
|
1548
|
+
const fixture = await createFixture()
|
|
1549
|
+
const runtimePath = path.join(fixture.root, "runtime")
|
|
1550
|
+
const daemonLogPath = path.join(fixture.root, "same-authority-replacement.log")
|
|
1551
|
+
const daemonPidPath = path.join(fixture.root, "same-authority-replacement.pid")
|
|
1552
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1553
|
+
const ensureArgs = [
|
|
1554
|
+
"ensure-daemon", "--config", fixture.configPath,
|
|
1555
|
+
"--daemon-log-path", daemonLogPath,
|
|
1556
|
+
"--daemon-pid-path", daemonPidPath,
|
|
1557
|
+
"--daemon-runtime-path", runtimePath,
|
|
1558
|
+
"--daemon-start-timeout-ms", "5000"
|
|
1559
|
+
]
|
|
1560
|
+
|
|
1561
|
+
try {
|
|
1562
|
+
const first = await runCli(ensureArgs)
|
|
1563
|
+
|
|
1564
|
+
assert.equal(first.code, 0, first.output)
|
|
1565
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
1566
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
1567
|
+
|
|
1568
|
+
await fs.rm(fixture.socketPath, {force: true})
|
|
1569
|
+
const replacement = await runCli(ensureArgs)
|
|
1570
|
+
|
|
1571
|
+
assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
|
|
1572
|
+
const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
1573
|
+
|
|
1574
|
+
assert.equal(after.activeReleaseId, "v1")
|
|
1575
|
+
assert.notEqual(after.daemonPid, before.daemonPid)
|
|
1576
|
+
await waitForProcessExit(before.daemonPid)
|
|
1577
|
+
|
|
1578
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
1579
|
+
|
|
1580
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1581
|
+
await shutdown
|
|
1582
|
+
} finally {
|
|
1583
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1584
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1585
|
+
}
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1078
1588
|
test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
|
|
1079
1589
|
const fixture = await createFixture()
|
|
1080
1590
|
const oldControlPath = fixture.socketPath
|
|
@@ -1137,6 +1647,16 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
|
|
|
1137
1647
|
assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
|
|
1138
1648
|
assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
|
|
1139
1649
|
assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
|
|
1650
|
+
assert.ok(after.daemonPid)
|
|
1651
|
+
await fs.writeFile(daemonLogPath, "")
|
|
1652
|
+
process.kill(after.daemonPid, "SIGKILL")
|
|
1653
|
+
const restartedState = await waitForState(fixture.statePath, (state) => state.daemonPid !== after.daemonPid, AbortSignal.timeout(5000))
|
|
1654
|
+
const restarted = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
|
|
1655
|
+
|
|
1656
|
+
assert.equal(restarted.daemonPid, restartedState.daemonPid)
|
|
1657
|
+
assert.equal(Number((await fs.readFile(daemonPidPath, "utf8")).trim()), restarted.daemonPid)
|
|
1658
|
+
assert.deepEqual(restarted.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
|
|
1659
|
+
assert.match(await fs.readFile(daemonLogPath, "utf8"), /owner state recovered/)
|
|
1140
1660
|
|
|
1141
1661
|
const v3Path = await prepareRelease(fixture.root, "v3")
|
|
1142
1662
|
await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
|
|
@@ -1380,10 +1900,11 @@ async function stopFixtureGuardian(statePath) {
|
|
|
1380
1900
|
/**
|
|
1381
1901
|
* @param {string} statePath - State path.
|
|
1382
1902
|
* @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
|
|
1903
|
+
* @param {AbortSignal} [signal] - Optional deadline signal.
|
|
1383
1904
|
* @returns {Promise<RecoveryState>} Matching state.
|
|
1384
1905
|
*/
|
|
1385
|
-
async function waitForState(statePath, predicate) {
|
|
1386
|
-
const watcher = fs.watch(path.dirname(statePath))
|
|
1906
|
+
async function waitForState(statePath, predicate, signal) {
|
|
1907
|
+
const watcher = fs.watch(path.dirname(statePath), {signal})
|
|
1387
1908
|
|
|
1388
1909
|
try {
|
|
1389
1910
|
const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
|
|
@@ -1437,17 +1958,6 @@ async function waitForFile(filePath, timeoutMs) {
|
|
|
1437
1958
|
}
|
|
1438
1959
|
}
|
|
1439
1960
|
|
|
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
1961
|
/**
|
|
1452
1962
|
* Opens a live WebSocket through the fixture proxy.
|
|
1453
1963
|
* @param {number} port - Proxy port.
|
|
@@ -1485,29 +1995,18 @@ function releaseProcessPid(status, releaseId, processId) {
|
|
|
1485
1995
|
return pid
|
|
1486
1996
|
}
|
|
1487
1997
|
|
|
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
1998
|
/**
|
|
1503
1999
|
* @param {string} configPath - Config path.
|
|
1504
2000
|
* @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
|
|
2001
|
+
* @param {{daemonPidPath?: string, startupTimeoutMs?: number}} [options] - Recovery command options.
|
|
1505
2002
|
* @returns {import("node:child_process").ChildProcess} Daemon process.
|
|
1506
2003
|
*/
|
|
1507
|
-
function spawnDaemon(configPath, bootstrap) {
|
|
2004
|
+
function spawnDaemon(configPath, bootstrap, options = {}) {
|
|
1508
2005
|
const args = [binPath, "daemon", "--config", configPath]
|
|
1509
2006
|
|
|
1510
2007
|
if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
|
|
2008
|
+
if (options.daemonPidPath) args.push("--guardian-daemon-pid-path", options.daemonPidPath)
|
|
2009
|
+
if (options.startupTimeoutMs) args.push("--guardian-daemon-start-timeout-ms", String(options.startupTimeoutMs))
|
|
1511
2010
|
return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
|
|
1512
2011
|
}
|
|
1513
2012
|
|
|
@@ -1554,14 +2053,16 @@ async function runCli(args) {
|
|
|
1554
2053
|
/**
|
|
1555
2054
|
* @param {import("node:child_process").ChildProcess} child - Daemon child.
|
|
1556
2055
|
* @param {string} message - Structured log message.
|
|
2056
|
+
* @param {{allowChildExit?: boolean}} [options] - Whether inherited descriptors may outlive the original child.
|
|
1557
2057
|
*/
|
|
1558
|
-
async function waitForLog(child, message) {
|
|
2058
|
+
async function waitForLog(child, message, {allowChildExit = false} = {}) {
|
|
1559
2059
|
assert.ok(child.stdout)
|
|
1560
2060
|
child.stdout.setEncoding("utf8")
|
|
1561
2061
|
|
|
1562
2062
|
await new Promise((resolve, reject) => {
|
|
1563
2063
|
let buffer = ""
|
|
1564
2064
|
let stderr = ""
|
|
2065
|
+
const timer = setTimeout(() => finish(new Error(`Timed out waiting for daemon log ${message}: ${stderr.trim()}`)), 5000)
|
|
1565
2066
|
const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
|
|
1566
2067
|
const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
|
|
1567
2068
|
/** @param {string} chunk - Output chunk. */
|
|
@@ -1579,6 +2080,7 @@ async function waitForLog(child, message) {
|
|
|
1579
2080
|
}
|
|
1580
2081
|
/** @param {Error} [error] - Failure. */
|
|
1581
2082
|
const finish = (error) => {
|
|
2083
|
+
clearTimeout(timer)
|
|
1582
2084
|
child.off("exit", onExit)
|
|
1583
2085
|
child.stdout?.off("data", onData)
|
|
1584
2086
|
child.stderr?.off("data", onErrorData)
|
|
@@ -1586,7 +2088,7 @@ async function waitForLog(child, message) {
|
|
|
1586
2088
|
else resolve(undefined)
|
|
1587
2089
|
}
|
|
1588
2090
|
|
|
1589
|
-
child.once("exit", onExit)
|
|
2091
|
+
if (!allowChildExit) child.once("exit", onExit)
|
|
1590
2092
|
child.stdout?.on("data", onData)
|
|
1591
2093
|
child.stderr?.setEncoding("utf8").on("data", onErrorData)
|
|
1592
2094
|
})
|