rollbridge 0.1.45 → 0.1.46

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.
@@ -0,0 +1,8 @@
1
+ ### Fixed
2
+
3
+ - Add explicit `recover-generation-transition --accept-retired-incumbent`
4
+ recovery that stops the exact failed candidate and durably fences degraded
5
+ incumbent web authority across owner recovery without reactivating either jobs
6
+ generation. A fresh deployment replaces the fence through normal cutover.
7
+ Incompatible owner replacement now accepts the unresolved transition's exact
8
+ retained candidate config without admitting unrelated config authority.
package/docs/cli.md CHANGED
@@ -207,6 +207,20 @@ instruction; see [Config reloads](config.md#config-reloads).
207
207
  - `--ensure-daemon` — start the daemon first if it isn't running (honors the
208
208
  same `--daemon-*` options as `ensure-daemon`).
209
209
 
210
+ ## `recover-generation-transition`
211
+
212
+ `rollbridge recover-generation-transition --release-path <path> --release-id <id>
213
+ --revision <sha> --previous-release-id <id> [--config <path>]
214
+ [--accept-retired-incumbent]`
215
+
216
+ By default, restores the incumbent before clearing an exact failed transition.
217
+ `--accept-retired-incumbent` instead requires an exact `restoring_previous`
218
+ journal, terminal restoration failure, retired candidate, retired incumbent
219
+ coordinator, and live incumbent proxy processes. It safely stops the failed
220
+ candidate and persists a `degraded_active` fence without reactivating either jobs
221
+ generation, reporting `jobsStatus: "degraded"`. Incumbent web survives owner
222
+ recovery, and a fresh normal deployment replaces the fence through normal cutover.
223
+
210
224
  ## `rollback`
211
225
 
212
226
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/cli.js CHANGED
@@ -215,17 +215,19 @@ export async function runCli(argv) {
215
215
 
216
216
  program
217
217
  .command("recover-generation-transition")
218
- .description("Restore the exact incumbent and retire a failed pre-commit generation candidate.")
218
+ .description("Recover an exact failed pre-commit generation transition.")
219
219
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
220
220
  .requiredOption("--release-path <path>", "Failed candidate release path")
221
221
  .requiredOption("--release-id <id>", "Failed candidate release id")
222
222
  .requiredOption("--revision <sha>", "Failed candidate revision")
223
223
  .requiredOption("--previous-release-id <id>", "Expected authoritative incumbent release id")
224
+ .option("--accept-retired-incumbent", "Persist terminal restoration as degraded incumbent web authority")
224
225
  .action(async (options) => {
225
226
  const configPath = await resolveConfigPath(options.config)
226
227
  const config = await loadConfig(configPath)
227
228
  const response = await sendControlCommand({
228
229
  command: {
230
+ acceptRetiredIncumbent: options.acceptRetiredIncumbent === true,
229
231
  command: "recover-generation-transition",
230
232
  previousReleaseId: options.previousReleaseId,
231
233
  releaseId: options.releaseId,
package/src/daemon.js CHANGED
@@ -24,7 +24,7 @@ const STATE_PERSIST_INTERVAL_MS = 5000
24
24
  * @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
25
25
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
26
26
  * @typedef {{disruptive: true, mode: "legacy-first-upgrade", reason: string}} OwnerTransition
27
- * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
27
+ * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "degraded_active" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
28
28
  * @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
29
29
  * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, generationTransition?: GenerationTransition, ownerRecovery: {configDigest: string, ready: boolean} | undefined, ownerTransition?: OwnerTransition, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
30
30
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
@@ -127,7 +127,7 @@ export default class RollbridgeDaemon {
127
127
  const transition = this.generationTransition
128
128
  const recoveredReplacementProxy = this.guardian ? await this.finalizeRecoveredOwnerReplacement() : false
129
129
 
130
- if (this.guardian && transition && transition.phase !== "committed" && !transition.error) {
130
+ if (this.guardian && transition && transition.phase !== "committed" && transition.phase !== "degraded_active" && !transition.error) {
131
131
  this.generationTransitionRecovery = true
132
132
  try {
133
133
  await this.executeOwnerMutation("recover generation transition", async () => {
@@ -505,10 +505,8 @@ export default class RollbridgeDaemon {
505
505
  if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
506
506
  const unresolvedTransition = transfer.snapshot.generationTransition
507
507
 
508
- const transferredTransitionConfigDigest = transfer.config ? ownerConfigDigest(transfer.config) : undefined
509
508
  if (unresolvedTransition && unresolvedTransition.phase !== "committed" &&
510
- unresolvedTransition.configDigest !== this.ownerRecoveryConfigDigest() &&
511
- unresolvedTransition.configDigest !== transferredTransitionConfigDigest) {
509
+ !ownerReplacementTransitionAuthorityMatches(transfer, unresolvedTransition, this.ownerRecoveryConfigDigest())) {
512
510
  throw new Error(`Owner replacement cannot change config authority while unresolved generation transition ${unresolvedTransition.candidateReleaseId} remains at ${unresolvedTransition.phase}`)
513
511
  }
514
512
  const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
@@ -1342,6 +1340,7 @@ export default class RollbridgeDaemon {
1342
1340
 
1343
1341
  if (commandName === "recover-generation-transition") {
1344
1342
  return await this.executeOwnerMutation("recover generation transition", async () => await this.recoverGenerationTransition({
1343
+ acceptRetiredIncumbent: data.acceptRetiredIncumbent === true,
1345
1344
  previousReleaseId: requiredString(data.previousReleaseId, "previousReleaseId"),
1346
1345
  releaseId: requiredString(data.releaseId, "releaseId"),
1347
1346
  releasePath: requiredString(data.releasePath, "releasePath"),
@@ -1561,7 +1560,7 @@ export default class RollbridgeDaemon {
1561
1560
  const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
1562
1561
  const transition = this.generationTransition
1563
1562
 
1564
- if (transition && transition.phase !== "committed") {
1563
+ if (transition && transition.phase !== "committed" && transition.phase !== "degraded_active") {
1565
1564
  this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
1566
1565
  return await this.resumeGenerationTransition()
1567
1566
  }
@@ -1639,6 +1638,7 @@ export default class RollbridgeDaemon {
1639
1638
  const transition = this.generationTransition
1640
1639
 
1641
1640
  if (!transition) throw new Error("No release generation transition to resume")
1641
+ if (transition.phase === "degraded_active") throw new Error("Degraded incumbent authority requires a fresh deployment, not transition resume")
1642
1642
  if (transition.phase === "restoring_previous" || transition.phase === "retiring_failed_candidate") {
1643
1643
  return await this.compensatePreCommitActivationFailure()
1644
1644
  }
@@ -1874,7 +1874,7 @@ export default class RollbridgeDaemon {
1874
1874
 
1875
1875
  /**
1876
1876
  * Authenticated control recovery for one exact failed pre-commit transition.
1877
- * @param {{previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
1877
+ * @param {{acceptRetiredIncumbent?: boolean, previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
1878
1878
  * @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
1879
1879
  */
1880
1880
  async recoverGenerationTransition(identity) {
@@ -1894,9 +1894,12 @@ export default class RollbridgeDaemon {
1894
1894
  if (transition.previousReleaseId !== identity.previousReleaseId) {
1895
1895
  throw new Error(`Generation transition previous release is ${transition.previousReleaseId}; refusing stale recovery for ${identity.previousReleaseId}`)
1896
1896
  }
1897
- if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
1897
+ if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate" && transition.phase !== "degraded_active") {
1898
1898
  throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not a safe failed pre-commit transition`)
1899
1899
  }
1900
+ if (transition.phase === "degraded_active" && !identity.acceptRetiredIncumbent) {
1901
+ throw new Error("Degraded incumbent authority may only be acknowledged with --accept-retired-incumbent or replaced by a fresh deployment")
1902
+ }
1900
1903
  const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
1901
1904
 
1902
1905
  this.assertReloadCompatible(nextConfig)
@@ -1909,9 +1912,76 @@ export default class RollbridgeDaemon {
1909
1912
  if (this.activeRelease?.releaseId !== transition.previousReleaseId) {
1910
1913
  throw new Error(`Previous release ${transition.previousReleaseId} is not the authoritative proxy target`)
1911
1914
  }
1915
+ if (identity.acceptRetiredIncumbent) return await this.acceptRetiredIncumbentTransition(transition)
1912
1916
  return await this.compensatePreCommitActivationFailure()
1913
1917
  }
1914
1918
 
1919
+ /**
1920
+ * Stops an exact failed candidate and persists degraded incumbent web authority until cutover.
1921
+ * @param {GenerationTransition} transition - Exact failed transition proved by the caller.
1922
+ * @returns {Promise<Record<string, JsonValue>>} Explicit degraded recovery result.
1923
+ */
1924
+ async acceptRetiredIncumbentTransition(transition) {
1925
+ if (transition.phase !== "restoring_previous" && transition.phase !== "degraded_active") throw new Error(`Retired-incumbent recovery requires exactly restoring_previous; transition ${transition.candidateReleaseId} is at ${transition.phase}`)
1926
+ if (!terminalRetirementFailure(transition.compensationError)) throw new Error("Retired-incumbent recovery requires a recorded restoration failure compatible with terminal retirement")
1927
+ if (!transition.previousReleaseId) throw new Error("Retired-incumbent recovery requires a retained previous release")
1928
+ const candidate = this.releases.get(transition.candidateReleaseId)
1929
+ const previous = this.releases.get(transition.previousReleaseId)
1930
+
1931
+ if (!candidate) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
1932
+ if (!previous) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
1933
+ if (candidate.releasePath !== transition.candidateReleasePath || candidate.revision !== transition.candidateRevision || ownerConfigDigest(candidate.config) !== transition.configDigest) {
1934
+ throw new Error(`Generation transition candidate ${candidate.releaseId} does not retain its exact path, revision, and config authority`)
1935
+ }
1936
+ if (candidate.state !== "draining" && candidate.state !== "stopped") throw new Error(`Failed candidate ${candidate.releaseId} must be retired before accepting a retired incumbent`)
1937
+ if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
1938
+ const activationConfig = previous.config.processes.find((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
1939
+ const coordinator = activationConfig ? previous.getProcesses(activationConfig.id)[0]?.process : undefined
1940
+ if (!coordinator || coordinator.status().lifecycleRole !== "retired") throw new Error(`Previous release ${previous.releaseId} does not retain a retired generation coordinator`)
1941
+ if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
1942
+ await candidate.drainAndStop(candidate.config.proxy.drainTimeoutMs, candidate.config)
1943
+ const candidateProcesses = candidate.status().processes
1944
+
1945
+ if (candidate.state !== "stopped" || candidateProcesses.some(({pid, state}) => pid !== undefined || (state !== "stopped" && state !== "failed"))) {
1946
+ throw new Error(`Failed candidate ${candidate.releaseId} did not fully stop during retired-incumbent recovery`)
1947
+ }
1948
+ if (this.activeRelease !== previous || !releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} lost live proxy/web authority during retired-incumbent recovery`)
1949
+ const result = /** @type {Record<string, JsonValue>} */ ({
1950
+ activeReleaseId: previous.releaseId,
1951
+ candidateReleaseId: candidate.releaseId,
1952
+ failedCandidateStatus: candidate.state,
1953
+ jobsStatus: "degraded",
1954
+ previousReleaseId: previous.releaseId,
1955
+ recoveryStatus: "retired_incumbent_accepted"
1956
+ })
1957
+
1958
+ if (transition.phase === "degraded_active") return result
1959
+ const previousError = transition.error
1960
+ const previousJournalRevision = transition.journalRevision
1961
+ const previousUpdatedAt = transition.updatedAt
1962
+
1963
+ transition.phase = "degraded_active"
1964
+ transition.error = undefined
1965
+ transition.journalRevision = (transition.journalRevision ?? 0) + 1
1966
+ transition.updatedAt = new Date().toISOString()
1967
+ try {
1968
+ await this.checkpointGenerationTransition()
1969
+ } catch (error) {
1970
+ transition.phase = "restoring_previous"
1971
+ transition.error = previousError
1972
+ transition.journalRevision = previousJournalRevision
1973
+ transition.updatedAt = previousUpdatedAt
1974
+ try {
1975
+ await this.publishOwnerState()
1976
+ } catch (publishError) {
1977
+ throw new AggregateError([error, publishError], "retired-incumbent recovery checkpoint and fence restoration failed", {cause: publishError})
1978
+ }
1979
+ throw new Error(`retired-incumbent recovery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`, {cause: error})
1980
+ }
1981
+ this.logger("retired incumbent accepted as jobs-degraded", result)
1982
+ return result
1983
+ }
1984
+
1915
1985
  /**
1916
1986
  * @param {GenerationTransition} transition - Pending or committed exact transition.
1917
1987
  * @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
@@ -2865,6 +2935,54 @@ export function ownerConfigDigest(config) {
2865
2935
  return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex")
2866
2936
  }
2867
2937
 
2938
+ /**
2939
+ * @param {PrivateOwnerState} transfer - Authenticated private incumbent state.
2940
+ * @param {GenerationTransition} transition - Unresolved generation transition.
2941
+ * @param {string} replacementConfigDigest - Proposed replacement authority.
2942
+ * @returns {boolean} Whether an existing exact config authority admits replacement.
2943
+ */
2944
+ export function ownerReplacementTransitionAuthorityMatches(transfer, transition, replacementConfigDigest) {
2945
+ const retainedRelease = transfer.snapshot.releases.find((release) =>
2946
+ release.releaseId === transition.candidateReleaseId &&
2947
+ release.releasePath === transition.candidateReleasePath &&
2948
+ release.revision === transition.candidateRevision)
2949
+ const retainedConfig = transfer.releaseConfigs?.[transition.candidateReleaseId]
2950
+ const retainedDigest = retainedRelease && retainedConfig ? ownerConfigDigest(retainedConfig) : undefined
2951
+
2952
+ return transition.configDigest === replacementConfigDigest ||
2953
+ transition.configDigest === ownerConfigDigest(transfer.config) ||
2954
+ transition.configDigest === retainedDigest
2955
+ }
2956
+
2957
+ /**
2958
+ * @param {string | undefined} failure - Recorded incumbent restoration failure.
2959
+ * @returns {boolean} Whether the diagnostic can represent terminal external retirement.
2960
+ */
2961
+ function terminalRetirementFailure(failure) {
2962
+ return typeof failure === "string" && (
2963
+ /^Cannot activate .+ generation from retired$/iu.test(failure) ||
2964
+ /^activate command exited non-zero with status \d+$/u.test(failure)
2965
+ )
2966
+ }
2967
+
2968
+ /**
2969
+ * @param {ReleaseGroup} release - Expected authoritative release.
2970
+ * @returns {boolean} Whether every configured proxy process is live.
2971
+ */
2972
+ function releaseOwnsLiveProxyTraffic(release) {
2973
+ const proxiedConfigs = release.config.processes.filter((processConfig) => processConfig.policy === "proxied")
2974
+
2975
+ return proxiedConfigs.length > 0 && proxiedConfigs.every((processConfig) => {
2976
+ const instances = release.getProcesses(processConfig.id)
2977
+
2978
+ return instances.length === processConfig.replicas && instances.every(({process}) => {
2979
+ const {pid, state} = process.status()
2980
+
2981
+ return pid !== undefined && state === "running"
2982
+ })
2983
+ })
2984
+ }
2985
+
2868
2986
  /**
2869
2987
  * Accepts only the two exact authenticated diagnostics emitted by pre-split guardians.
2870
2988
  * @param {string} diagnostic - Guardian response diagnostic.
@@ -44,7 +44,7 @@ test("completion bash prints a sourceable script with commands and option flags"
44
44
  assert.match(output, /compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
45
45
  // A command's own options are completed after the command.
46
46
  assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
47
- assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id"/)
47
+ assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
48
48
  assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
49
49
  assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
50
50
  })
@@ -55,7 +55,7 @@ test("completion zsh prints a #compdef script with per-command options", async (
55
55
  assert.match(output, /^#compdef rollbridge/)
56
56
  assert.match(output, /compdef _rollbridge rollbridge/)
57
57
  assert.match(output, /commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
58
- assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id/)
58
+ assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
59
59
  assert.match(output, /events\) compadd -- [^\n]*--limit/)
60
60
  })
61
61
 
@@ -11,7 +11,7 @@ import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
12
  import {normalizeConfig} from "../src/config.js"
13
13
  import {openControlSession, sendControlCommand} from "../src/control-client.js"
14
- import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
+ import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic, ownerConfigDigest, ownerReplacementTransitionAuthorityMatches} from "../src/daemon.js"
15
15
  import GuardianClient from "../src/guardian-client.js"
16
16
  import {findAvailablePort} from "../src/port-allocator.js"
17
17
  import {waitForProcessExit} from "./support/process.js"
@@ -1748,6 +1748,108 @@ test("config-changing owner replacement proceeds after activation compensation c
1748
1748
  }
1749
1749
  })
1750
1750
 
1751
+ test("owner replacement admits only the unresolved transition's exact retained candidate config", () => {
1752
+ const exactConfig = normalizeConfig(config({controlPath: "/tmp/exact.sock", extraCompanion: false, statePath: "/tmp/exact-state.json"}))
1753
+ const unrelatedConfig = normalizeConfig(config({controlPath: "/tmp/unrelated.sock", extraCompanion: true, statePath: "/tmp/exact-state.json"}))
1754
+ const replacementConfig = structuredClone(unrelatedConfig)
1755
+
1756
+ replacementConfig.releaseRetention.keep += 1
1757
+ const transition = /** @type {Parameters<typeof ownerReplacementTransitionAuthorityMatches>[1]} */ (/** @type {import("../src/json.js").JsonValue} */ ({
1758
+ candidateReleaseId: "v2",
1759
+ candidateReleasePath: "/srv/releases/v2",
1760
+ candidateRevision: "revision-v2",
1761
+ configDigest: ownerConfigDigest(exactConfig)
1762
+ }))
1763
+ /**
1764
+ * @param {import("../src/config.js").RollbridgeConfig | undefined} candidateConfig - Candidate authority.
1765
+ * @param {string} [releasePath] - Retained candidate path.
1766
+ * @returns {boolean} Whether replacement is admitted.
1767
+ */
1768
+ const admitted = (candidateConfig, releasePath = "/srv/releases/v2") => ownerReplacementTransitionAuthorityMatches(
1769
+ /** @type {Parameters<typeof ownerReplacementTransitionAuthorityMatches>[0]} */ (/** @type {import("../src/json.js").JsonValue} */ ({
1770
+ config: unrelatedConfig,
1771
+ releaseConfigs: candidateConfig ? {v2: candidateConfig} : {},
1772
+ snapshot: {releases: [{releaseId: "v2", releasePath, revision: "revision-v2"}]}
1773
+ })),
1774
+ transition,
1775
+ ownerConfigDigest(replacementConfig)
1776
+ )
1777
+
1778
+ assert.equal(admitted(exactConfig), true)
1779
+ assert.equal(admitted(unrelatedConfig), false)
1780
+ assert.equal(admitted(exactConfig, "/srv/releases/wrong"), false)
1781
+ })
1782
+
1783
+ test("owner replacement preserves accepted degraded incumbent web authority", async () => {
1784
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-degraded-active-"))
1785
+ const socketPath = path.join(root, "rollbridge.sock")
1786
+ const statePath = path.join(root, "state.json")
1787
+ const configPath = path.join(root, "rollbridge.cjs")
1788
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
1789
+ const retiredMarkerPath = path.join(root, "incumbent.retired")
1790
+ const v1Path = path.join(root, "v1")
1791
+ const v2Path = path.join(root, "v2")
1792
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
1793
+ let owner
1794
+ let replacement
1795
+ const failedConfig = () => {
1796
+ const raw = config({activationLogPath: lifecycleLogPath, controlPath: socketPath, extraCompanion: false, proxyPort, statePath})
1797
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
1798
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1799
+ const workerLifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (worker?.lifecycle)
1800
+ const generation = processes.find((processConfig) => processConfig.id === "generation-main")
1801
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generation?.lifecycle)
1802
+
1803
+ delete workerLifecycle.drainCommand
1804
+ workerLifecycle.drainTimeoutMs = 0
1805
+ lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; [ "$ROLLBRIDGE_RELEASE_ID" != v1 ] || [ ! -f ${JSON.stringify(retiredMarkerPath)} ] || exit 26; printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1806
+ lifecycle.quietCommand = `touch ${JSON.stringify(retiredMarkerPath)}; printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1807
+ return raw
1808
+ }
1809
+
1810
+ try {
1811
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
1812
+ await writeConfig(configPath, failedConfig())
1813
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
1814
+ await waitForLog(owner, "control socket listening")
1815
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
1816
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath}), /pre-commit compensation failed.*status 26/i)
1817
+ const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
1818
+ const webPid = releaseProcessPid(before, "v1", "web")
1819
+
1820
+ assert.equal(/** @type {{releaseId: string, state: string}[]} */ (before.releases).find(({releaseId}) => releaseId === "v2")?.state, "draining")
1821
+ const recovery = await sendControlCommand({command: {
1822
+ acceptRetiredIncumbent: true,
1823
+ command: "recover-generation-transition",
1824
+ previousReleaseId: "v1",
1825
+ releaseId: "v2",
1826
+ releasePath: v2Path,
1827
+ revision: "v2"
1828
+ }, path: socketPath})
1829
+ const accepted = await sendControlCommand({command: {command: "status"}, path: socketPath})
1830
+
1831
+ assert.equal(recovery.jobsStatus, "degraded")
1832
+ assert.equal(/** @type {{releaseId: string, state: string}[]} */ (accepted.releases).find(({releaseId}) => releaseId === "v2")?.state, "stopped")
1833
+ assert.equal(/** @type {{phase?: string}} */ (accepted.generationTransition).phase, "degraded_active")
1834
+ replacement = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1835
+ await waitForLog(replacement, "owner replacement committed")
1836
+ await new Promise((resolve) => setTimeout(resolve, 250))
1837
+ const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
1838
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/release`)
1839
+
1840
+ assert.equal(recovered.activeReleaseId, "v1")
1841
+ assert.equal(/** @type {{phase?: string}} */ (recovered.generationTransition).phase, "degraded_active")
1842
+ assert.equal(releaseProcessPid(recovered, "v1", "web"), webPid)
1843
+ assert.equal(response.status, 200)
1844
+ assert.equal((await response.text()).trim(), "v1")
1845
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\n")
1846
+ } finally {
1847
+ for (const child of [owner, replacement]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
1848
+ await stopGuardian(statePath)
1849
+ await fs.rm(root, {force: true, recursive: true})
1850
+ }
1851
+ })
1852
+
1751
1853
  test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
1752
1854
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
1753
1855
  const socketPath = path.join(root, "rollbridge.sock")
@@ -757,6 +757,90 @@ test("candidate activation failure reports restoration failure and exact recover
757
757
  }
758
758
  })
759
759
 
760
+ test("explicit recovery stops the exact failed candidate and fences degraded incumbent authority", async () => {
761
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true})
762
+ const daemon = await startDaemon(fixture.config)
763
+
764
+ try {
765
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
766
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
767
+
768
+ assert.ok(incumbentCoordinator)
769
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("Cannot activate background jobs generation from retired") }
770
+ await assert.rejects(
771
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
772
+ /Cannot activate background jobs generation from retired/i
773
+ )
774
+
775
+ const candidate = daemon.releases.get("v2")
776
+ const transition = daemon.generationTransition
777
+ const incumbentWebPid = statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid
778
+ const exactRecovery = (overrides = {}) => sendControlCommand({
779
+ command: {
780
+ acceptRetiredIncumbent: true,
781
+ command: "recover-generation-transition",
782
+ previousReleaseId: "v1",
783
+ releaseId: "v2",
784
+ releasePath: fixture.root,
785
+ revision: "v2",
786
+ ...overrides
787
+ },
788
+ path: fixture.config.control.path
789
+ })
790
+
791
+ assert.ok(candidate && transition && incumbentWebPid)
792
+ assert.equal(candidate.state, "draining", "ordinary failed compensation leaves the candidate draining")
793
+
794
+ const retainedCandidateConfig = candidate.config
795
+
796
+ candidate.config = {...candidate.config, releaseRetention: {...candidate.config.releaseRetention, keep: candidate.config.releaseRetention.keep + 1}}
797
+ await assert.rejects(() => exactRecovery(), /does not retain its exact path, revision, and config authority/i)
798
+ candidate.config = retainedCandidateConfig
799
+ await assert.rejects(() => exactRecovery({previousReleaseId: "wrong-v1"}), /refusing stale recovery/i)
800
+ await assert.rejects(() => exactRecovery({revision: "wrong-v2"}), /exact same release, path, revision, and config authority/i)
801
+ transition.phase = "retiring_failed_candidate"
802
+ await assert.rejects(() => exactRecovery(), /exactly restoring_previous/i)
803
+ transition.phase = "restoring_previous"
804
+ const terminalFailure = transition.compensationError
805
+
806
+ transition.compensationError = "incumbent activation was temporarily unavailable"
807
+ await assert.rejects(() => exactRecovery(), /terminal retirement/i)
808
+ transition.compensationError = terminalFailure
809
+ const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
810
+
811
+ daemon.checkpointGenerationTransition = async () => { throw new Error("injected checkpoint failure") }
812
+ await assert.rejects(() => exactRecovery(), /checkpoint failed: injected checkpoint failure/i)
813
+ assert.equal(daemon.generationTransition, transition)
814
+ daemon.checkpointGenerationTransition = checkpoint
815
+
816
+ const eventsBeforeRecovery = await lifecycleEvents(fixture.lifecycleLogPath)
817
+ const recovery = await exactRecovery()
818
+
819
+ assert.equal(recovery.recoveryStatus, "retired_incumbent_accepted")
820
+ assert.equal(recovery.jobsStatus, "degraded")
821
+ assert.equal(daemon.status().generationTransition?.phase, "degraded_active")
822
+ assert.equal(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid, incumbentWebPid)
823
+ assert.equal(await fetchText(daemon, "/release"), "v1")
824
+ assert.equal(candidate.state, "stopped")
825
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), eventsBeforeRecovery, "recovery must not activate either retained generation")
826
+ const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
827
+
828
+ assert.equal(/** @type {{phase?: string} | undefined} */ (persisted?.generationTransition)?.phase, "degraded_active")
829
+ await assert.rejects(() => daemon.deploy({releaseId: "bad-v3", releasePath: fixture.root, revision: "bad-v3"}), /health check failed/i)
830
+ assert.equal(daemon.status().generationTransition?.phase, "degraded_active")
831
+ assert.equal(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid, incumbentWebPid)
832
+ assert.equal(await fetchText(daemon, "/release"), "v1")
833
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
834
+ assert.equal(daemon.status().activeReleaseId, "v3")
835
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
836
+ assert.equal(await fetchText(daemon, "/release"), "v3")
837
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "retire:bad-v3", "retire:v1", "activate:v3"])
838
+ } finally {
839
+ await daemon.shutdown()
840
+ await fs.rm(fixture.root, {force: true, recursive: true})
841
+ }
842
+ })
843
+
760
844
  test("candidate activation failure compensates to the incumbent and admits a different later release", async () => {
761
845
  const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
762
846
  const daemon = await startDaemon(fixture.config)