rollbridge 0.1.31 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -6
- package/README.md +28 -12
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +9 -0
- package/docs/cli.md +10 -7
- package/docs/config.md +41 -13
- package/docs/tensorbuzz-runbook.md +4 -2
- package/docs/velocious.md +19 -7
- package/docs/workers.md +18 -9
- package/examples/tensorbuzz.com.js +9 -5
- package/package.json +1 -1
- package/src/config.js +32 -2
- package/src/daemon.js +264 -20
- package/src/guardian-client.js +41 -3
- package/src/managed-process.js +85 -13
- package/src/process-guardian.js +13 -1
- package/src/release-group.js +53 -14
- package/test/config-validation.test.js +44 -0
- package/test/guardian-client.test.js +39 -0
- package/test/managed-process.test.js +33 -0
- package/test/owner-recovery.test.js +396 -6
- package/test/owner-replacement.test.js +107 -2
- package/test/rollbridge.test.js +333 -6
package/test/rollbridge.test.js
CHANGED
|
@@ -505,6 +505,277 @@ test("candidate activation retires jobs-main with its workers without waiting fo
|
|
|
505
505
|
}
|
|
506
506
|
})
|
|
507
507
|
|
|
508
|
+
test("opt-in generation lifecycle retires the old generation before activating and committing the candidate", async () => {
|
|
509
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
510
|
+
const daemon = await startDaemon(fixture.config)
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
514
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
515
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
516
|
+
|
|
517
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
518
|
+
|
|
519
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
520
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
521
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed")
|
|
522
|
+
} finally {
|
|
523
|
+
await daemon.shutdown()
|
|
524
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test("generation commit is durable before awaited post-transition work", async () => {
|
|
529
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
|
|
530
|
+
const daemon = await startDaemon(fixture.config)
|
|
531
|
+
const replaceSingletons = daemon.replaceSingletons.bind(daemon)
|
|
532
|
+
/** @type {() => void} */
|
|
533
|
+
let markReplacementStarted = () => {}
|
|
534
|
+
/** @type {() => void} */
|
|
535
|
+
let releaseReplacement = () => {}
|
|
536
|
+
const replacementStarted = new Promise((resolve) => { markReplacementStarted = () => resolve(undefined) })
|
|
537
|
+
const replacementGate = new Promise((resolve) => { releaseReplacement = () => resolve(undefined) })
|
|
538
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
539
|
+
let deployPromise
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
543
|
+
daemon.replaceSingletons = async (release) => {
|
|
544
|
+
if (release.releaseId === "v2") {
|
|
545
|
+
markReplacementStarted()
|
|
546
|
+
await replacementGate
|
|
547
|
+
}
|
|
548
|
+
await replaceSingletons(release)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
deployPromise = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
552
|
+
await replacementStarted
|
|
553
|
+
|
|
554
|
+
const persisted = /** @type {{activeReleaseId?: string, generationTransition?: {phase?: string}, singletonReleaseIds?: Record<string, string>} | undefined} */ (await readState(fixture.statePath))
|
|
555
|
+
|
|
556
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
557
|
+
assert.equal(persisted?.activeReleaseId, "v2")
|
|
558
|
+
assert.equal(persisted?.generationTransition?.phase, "committed_pending")
|
|
559
|
+
assert.equal(persisted?.singletonReleaseIds?.["jobs-main"], "v1")
|
|
560
|
+
|
|
561
|
+
releaseReplacement()
|
|
562
|
+
await deployPromise
|
|
563
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed")
|
|
564
|
+
} finally {
|
|
565
|
+
releaseReplacement()
|
|
566
|
+
await deployPromise?.catch(() => {})
|
|
567
|
+
await daemon.shutdown()
|
|
568
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
569
|
+
}
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
test("exact committed retry finishes pending singleton replacement before success", async () => {
|
|
573
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, singletonCwd: "{{releasePath}}/{{releaseId}}", webDependsOnService: true})
|
|
574
|
+
const daemon = await startDaemon(fixture.config)
|
|
575
|
+
|
|
576
|
+
await fs.mkdir(path.join(fixture.root, "v1"))
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
580
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /ENOENT/)
|
|
581
|
+
|
|
582
|
+
assert.equal(daemon.status().activeReleaseId, "v2", "traffic remains durably committed")
|
|
583
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed_pending")
|
|
584
|
+
assert.notEqual(daemon.status().singletons[0]?.process.state, "running")
|
|
585
|
+
|
|
586
|
+
await fs.mkdir(path.join(fixture.root, "v2"))
|
|
587
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
588
|
+
|
|
589
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed")
|
|
590
|
+
assert.equal(daemon.status().singletons[0]?.process.state, "running")
|
|
591
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
592
|
+
} finally {
|
|
593
|
+
await daemon.shutdown()
|
|
594
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
test("first generation is not committed when its activation acknowledgement fails", async () => {
|
|
599
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
600
|
+
const daemon = await startDaemon(fixture.config)
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"}), /activate command exited non-zero/)
|
|
604
|
+
const status = daemon.status()
|
|
605
|
+
|
|
606
|
+
assert.equal(status.activeReleaseId, null)
|
|
607
|
+
assert.equal(status.generationTransition?.phase, "activating_candidate")
|
|
608
|
+
assert.deepEqual(status.releaseReferences.map((reference) => reference.releaseId), ["v1"])
|
|
609
|
+
} finally {
|
|
610
|
+
await daemon.shutdown()
|
|
611
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test("retirement failure retains the exact transition, blocks other deploys, and exact resume continues it", async () => {
|
|
616
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceQuietFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
617
|
+
const daemon = await startDaemon(fixture.config)
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
621
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /retirement quiescence failed/)
|
|
622
|
+
|
|
623
|
+
const failed = daemon.status()
|
|
624
|
+
|
|
625
|
+
assert.equal(failed.activeReleaseId, "v1")
|
|
626
|
+
assert.equal(failed.generationTransition?.phase, "retiring_previous")
|
|
627
|
+
assert.match(String(failed.generationTransition?.error), /quiet command exited non-zero/)
|
|
628
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
629
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
|
|
630
|
+
|
|
631
|
+
await fs.writeFile(fixture.retirementGatePath, "allow\n")
|
|
632
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
633
|
+
|
|
634
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
635
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
636
|
+
} finally {
|
|
637
|
+
await daemon.shutdown()
|
|
638
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
|
|
643
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
644
|
+
const daemon = await startDaemon(fixture.config)
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
648
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
|
|
649
|
+
|
|
650
|
+
const failed = daemon.status()
|
|
651
|
+
|
|
652
|
+
assert.equal(failed.activeReleaseId, "v1")
|
|
653
|
+
assert.equal(failed.generationTransition?.phase, "activating_candidate")
|
|
654
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
|
|
655
|
+
|
|
656
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
657
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
658
|
+
|
|
659
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
660
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
661
|
+
} finally {
|
|
662
|
+
await daemon.shutdown()
|
|
663
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
664
|
+
}
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
test("unresolved generation transition fences stop, restart, and rollback mutations", async () => {
|
|
668
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
669
|
+
const daemon = await startDaemon(fixture.config)
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
673
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
|
|
674
|
+
|
|
675
|
+
await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
|
|
676
|
+
await assert.rejects(() => daemon.restartProcesses({processId: "beacon"}), /cannot restart.*generation transition.*unresolved/i)
|
|
677
|
+
await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
|
|
678
|
+
|
|
679
|
+
assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
|
|
680
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
681
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
682
|
+
assert.equal(await fetchText(daemon, "/release"), "v2")
|
|
683
|
+
} finally {
|
|
684
|
+
await daemon.shutdown()
|
|
685
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
test("active generation restores its exact lifecycle role after coordinator auto-restart", async () => {
|
|
690
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
|
|
691
|
+
const daemon = await startDaemon(fixture.config)
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
695
|
+
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
696
|
+
|
|
697
|
+
assert.ok(coordinator?.pid)
|
|
698
|
+
process.kill(-coordinator.pid, "SIGKILL")
|
|
699
|
+
await waitFor(async () => (await lifecycleEvents(fixture.lifecycleLogPath)).length === 2 && statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state === "running", 3000)
|
|
700
|
+
|
|
701
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "activate:v1"])
|
|
702
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state, "running")
|
|
703
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
704
|
+
} finally {
|
|
705
|
+
await daemon.shutdown()
|
|
706
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
707
|
+
}
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
test("failed active-role restoration is loud and never reports the restarted coordinator running", async () => {
|
|
711
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", webDependsOnService: true})
|
|
712
|
+
const daemon = await startDaemon(fixture.config)
|
|
713
|
+
|
|
714
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
718
|
+
await fs.rm(fixture.activationGatePath)
|
|
719
|
+
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
720
|
+
|
|
721
|
+
assert.ok(coordinator?.pid)
|
|
722
|
+
process.kill(-coordinator.pid, "SIGKILL")
|
|
723
|
+
await waitFor(() => {
|
|
724
|
+
const status = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
725
|
+
|
|
726
|
+
return status?.state === "failed" && status.restarts === 1
|
|
727
|
+
}, 3000)
|
|
728
|
+
|
|
729
|
+
const failed = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
730
|
+
|
|
731
|
+
assert.equal(failed?.lifecycleRole, "active")
|
|
732
|
+
assert.equal(failed?.restarts, 1, "role restoration failure must not create an internal retry loop")
|
|
733
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
734
|
+
} finally {
|
|
735
|
+
await daemon.shutdown()
|
|
736
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
test("retired generation coordinator remains fenced after exit", async () => {
|
|
741
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
|
|
742
|
+
const daemon = await startDaemon(fixture.config)
|
|
743
|
+
/** @type {WebSocket | undefined} */
|
|
744
|
+
let socket
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
748
|
+
socket = await openWebSocket(daemon)
|
|
749
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
750
|
+
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
751
|
+
const coordinatorProcess = daemon.releases.get("v1")?.getProcess("beacon")
|
|
752
|
+
|
|
753
|
+
assert.ok(coordinator?.pid)
|
|
754
|
+
assert.ok(coordinatorProcess)
|
|
755
|
+
assert.equal(coordinator.lifecycleRole, "retired")
|
|
756
|
+
assert.equal(daemon.guardian?.processes.get("release:v1:beacon"), coordinatorProcess, "retirement refresh must preserve exact guardian event routing")
|
|
757
|
+
const exited = once(coordinatorProcess, "exit")
|
|
758
|
+
|
|
759
|
+
process.kill(-coordinator.pid, "SIGKILL")
|
|
760
|
+
const [exit] = await exited
|
|
761
|
+
const stopped = coordinatorProcess.status()
|
|
762
|
+
|
|
763
|
+
assert.equal(exit.code, null)
|
|
764
|
+
assert.equal(exit.id, "beacon")
|
|
765
|
+
assert.equal(exit.signal, "SIGKILL")
|
|
766
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
767
|
+
assert.equal(stopped.lifecycleRole, "retired")
|
|
768
|
+
assert.equal(stopped.pid, undefined)
|
|
769
|
+
assert.equal(stopped.restarts, 0)
|
|
770
|
+
assert.equal(stopped.state, "stopped")
|
|
771
|
+
assert.equal(coordinatorProcess.restartTimer, undefined, "retired process must not queue a restart")
|
|
772
|
+
} finally {
|
|
773
|
+
socket?.close()
|
|
774
|
+
await daemon.shutdown()
|
|
775
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
776
|
+
}
|
|
777
|
+
})
|
|
778
|
+
|
|
508
779
|
test("multiple retired jobs generations keep distinct endpoints and live references until completion", async () => {
|
|
509
780
|
const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, webDependsOnService: true})
|
|
510
781
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -819,7 +1090,11 @@ test("persisted daemon state excludes process commands, environment values, and
|
|
|
819
1090
|
|
|
820
1091
|
try {
|
|
821
1092
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
822
|
-
|
|
1093
|
+
const webProcess = daemon.activeRelease?.getProcess("web")
|
|
1094
|
+
|
|
1095
|
+
assert.ok(webProcess)
|
|
1096
|
+
await recordedLogLine(webProcess, secret)
|
|
1097
|
+
assert.ok(webProcess.status().logs.some((entry) => entry.line === secret), "secret output must be retained before persistence")
|
|
823
1098
|
|
|
824
1099
|
daemon.persistState()
|
|
825
1100
|
await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
|
|
@@ -1358,19 +1633,30 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1358
1633
|
})
|
|
1359
1634
|
|
|
1360
1635
|
/**
|
|
1361
|
-
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
1362
|
-
* @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1636
|
+
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
1637
|
+
* @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1363
1638
|
*/
|
|
1364
1639
|
async function createFixture(options = {}) {
|
|
1365
1640
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
|
|
1366
1641
|
const serviceLogPath = path.join(root, "service.log")
|
|
1367
1642
|
const serviceQuietPath = path.join(root, "service.quiet")
|
|
1643
|
+
const lifecycleLogPath = path.join(root, "service.lifecycle")
|
|
1644
|
+
const activationGatePath = path.join(root, "activation.allow")
|
|
1645
|
+
const retirementGatePath = path.join(root, "retirement.allow")
|
|
1368
1646
|
const singletonLogPath = path.join(root, "singleton.log")
|
|
1369
1647
|
const statePath = path.join(root, "rollbridge.state.json")
|
|
1370
1648
|
/** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
|
|
1371
1649
|
const processes = []
|
|
1372
1650
|
|
|
1373
1651
|
if (options.includeService || options.handoffService) {
|
|
1652
|
+
const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
|
|
1653
|
+
const lifecycle = options.handoffServiceActivate ? {
|
|
1654
|
+
activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
|
|
1655
|
+
quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
1656
|
+
} : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
|
|
1657
|
+
quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
|
|
1658
|
+
} : undefined
|
|
1659
|
+
|
|
1374
1660
|
processes.push({
|
|
1375
1661
|
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
|
|
1376
1662
|
...(options.handoffService ? {deployStrategy: "handoff"} : {}),
|
|
@@ -1378,7 +1664,7 @@ async function createFixture(options = {}) {
|
|
|
1378
1664
|
ROLLBRIDGE_SERVICE_LOG: serviceLogPath
|
|
1379
1665
|
},
|
|
1380
1666
|
id: "beacon",
|
|
1381
|
-
...(
|
|
1667
|
+
...(lifecycle ? {lifecycle} : {}),
|
|
1382
1668
|
policy: "service",
|
|
1383
1669
|
port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
|
|
1384
1670
|
restartDelayMs: 50
|
|
@@ -1463,10 +1749,18 @@ async function createFixture(options = {}) {
|
|
|
1463
1749
|
host: options.proxyHost || "127.0.0.1",
|
|
1464
1750
|
port: 0
|
|
1465
1751
|
},
|
|
1466
|
-
...(options.persistState ? {statePath} : {})
|
|
1752
|
+
...((options.persistState || options.handoffServiceActivate) ? {ownerRecovery: {reconnectGraceMs: 30000}, statePath} : {})
|
|
1467
1753
|
})
|
|
1468
1754
|
|
|
1469
|
-
return {config, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
|
|
1755
|
+
return {activationGatePath, config, lifecycleLogPath, retirementGatePath, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* @param {string} lifecycleLogPath - Fixture lifecycle event file.
|
|
1760
|
+
* @returns {Promise<string[]>} Ordered lifecycle events.
|
|
1761
|
+
*/
|
|
1762
|
+
async function lifecycleEvents(lifecycleLogPath) {
|
|
1763
|
+
return (await fs.readFile(lifecycleLogPath, "utf8")).trim().split("\n").filter(Boolean)
|
|
1470
1764
|
}
|
|
1471
1765
|
|
|
1472
1766
|
/**
|
|
@@ -1604,6 +1898,39 @@ async function waitFor(callback, timeoutMs = 3000) {
|
|
|
1604
1898
|
throw new Error("Timed out waiting for condition")
|
|
1605
1899
|
}
|
|
1606
1900
|
|
|
1901
|
+
/**
|
|
1902
|
+
* Resolves from retained output or the exact event that records it, and rejects if the
|
|
1903
|
+
* supervised process exits first.
|
|
1904
|
+
* @param {import("../src/managed-process.js").default} processInstance - Exact managed process.
|
|
1905
|
+
* @param {string} line - Complete output line to observe.
|
|
1906
|
+
* @returns {Promise<import("../src/managed-process.js").ManagedProcessLog>} Recorded log entry.
|
|
1907
|
+
*/
|
|
1908
|
+
async function recordedLogLine(processInstance, line) {
|
|
1909
|
+
const retained = processInstance.status().logs.find((entry) => entry.line === line)
|
|
1910
|
+
|
|
1911
|
+
if (retained) return retained
|
|
1912
|
+
return await new Promise((resolve, reject) => {
|
|
1913
|
+
/** @param {import("../src/managed-process.js").ManagedProcessLog} entry - Newly retained output. */
|
|
1914
|
+
const onLog = (entry) => {
|
|
1915
|
+
if (entry.line !== line) return
|
|
1916
|
+
cleanup()
|
|
1917
|
+
resolve(entry)
|
|
1918
|
+
}
|
|
1919
|
+
/** @param {{code: number | null, signal: import("node:child_process").ChildProcess["signalCode"]}} exit - Exact process exit. */
|
|
1920
|
+
const onExit = (exit) => {
|
|
1921
|
+
cleanup()
|
|
1922
|
+
reject(new Error(`Process ${processInstance.id} exited before recording expected output ${JSON.stringify(line)}: ${JSON.stringify(exit)}`))
|
|
1923
|
+
}
|
|
1924
|
+
const cleanup = () => {
|
|
1925
|
+
processInstance.off("log", onLog)
|
|
1926
|
+
processInstance.off("exit", onExit)
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
processInstance.on("log", onLog)
|
|
1930
|
+
processInstance.once("exit", onExit)
|
|
1931
|
+
})
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1607
1934
|
/**
|
|
1608
1935
|
* @param {RollbridgeDaemon} daemon - Daemon.
|
|
1609
1936
|
* @param {string} processId - Process id within the active release.
|