rollbridge 0.1.45 → 0.1.47
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/changelog.d/20260901-accept-retired-incumbent-recovery.md +8 -0
- package/docs/cli.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +3 -1
- package/src/daemon.js +129 -10
- package/test/completion.test.js +2 -2
- package/test/owner-replacement.test.js +103 -1
- package/test/rollbridge.test.js +84 -0
|
@@ -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
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("
|
|
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,8 +24,8 @@ 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
|
|
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
|
|
27
|
+
* @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "degraded_active" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
|
|
28
|
+
* @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, degradedIncumbent?: boolean, 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
|
|
31
31
|
* @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
|
|
@@ -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
|
|
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
|
}
|
|
@@ -1621,6 +1620,7 @@ export default class RollbridgeDaemon {
|
|
|
1621
1620
|
candidateReleasePath: release.releasePath,
|
|
1622
1621
|
candidateRevision: release.revision,
|
|
1623
1622
|
configDigest: ownerConfigDigest(nextConfig),
|
|
1623
|
+
degradedIncumbent: transition?.phase === "degraded_active",
|
|
1624
1624
|
journalRevision: 0,
|
|
1625
1625
|
phase: activationLifecycle ? "candidate_ready" : "activating_candidate",
|
|
1626
1626
|
previousReleaseId: previousRelease?.releaseId ?? null,
|
|
@@ -1639,6 +1639,7 @@ export default class RollbridgeDaemon {
|
|
|
1639
1639
|
const transition = this.generationTransition
|
|
1640
1640
|
|
|
1641
1641
|
if (!transition) throw new Error("No release generation transition to resume")
|
|
1642
|
+
if (transition.phase === "degraded_active") throw new Error("Degraded incumbent authority requires a fresh deployment, not transition resume")
|
|
1642
1643
|
if (transition.phase === "restoring_previous" || transition.phase === "retiring_failed_candidate") {
|
|
1643
1644
|
return await this.compensatePreCommitActivationFailure()
|
|
1644
1645
|
}
|
|
@@ -1651,7 +1652,7 @@ export default class RollbridgeDaemon {
|
|
|
1651
1652
|
let retirementFailure = activationLifecycle ? undefined : previousRelease?.retirementError
|
|
1652
1653
|
|
|
1653
1654
|
if (transition.phase === "candidate_ready") {
|
|
1654
|
-
if (previousRelease) await this.updateGenerationTransition("retiring_previous")
|
|
1655
|
+
if (previousRelease && !transition.degradedIncumbent) await this.updateGenerationTransition("retiring_previous")
|
|
1655
1656
|
else await this.updateGenerationTransition("previous_retired")
|
|
1656
1657
|
}
|
|
1657
1658
|
|
|
@@ -1874,7 +1875,7 @@ export default class RollbridgeDaemon {
|
|
|
1874
1875
|
|
|
1875
1876
|
/**
|
|
1876
1877
|
* Authenticated control recovery for one exact failed pre-commit transition.
|
|
1877
|
-
* @param {{previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
|
|
1878
|
+
* @param {{acceptRetiredIncumbent?: boolean, previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
|
|
1878
1879
|
* @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
|
|
1879
1880
|
*/
|
|
1880
1881
|
async recoverGenerationTransition(identity) {
|
|
@@ -1894,9 +1895,12 @@ export default class RollbridgeDaemon {
|
|
|
1894
1895
|
if (transition.previousReleaseId !== identity.previousReleaseId) {
|
|
1895
1896
|
throw new Error(`Generation transition previous release is ${transition.previousReleaseId}; refusing stale recovery for ${identity.previousReleaseId}`)
|
|
1896
1897
|
}
|
|
1897
|
-
if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
|
|
1898
|
+
if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate" && transition.phase !== "degraded_active") {
|
|
1898
1899
|
throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not a safe failed pre-commit transition`)
|
|
1899
1900
|
}
|
|
1901
|
+
if (transition.phase === "degraded_active" && !identity.acceptRetiredIncumbent) {
|
|
1902
|
+
throw new Error("Degraded incumbent authority may only be acknowledged with --accept-retired-incumbent or replaced by a fresh deployment")
|
|
1903
|
+
}
|
|
1900
1904
|
const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
|
|
1901
1905
|
|
|
1902
1906
|
this.assertReloadCompatible(nextConfig)
|
|
@@ -1909,9 +1913,76 @@ export default class RollbridgeDaemon {
|
|
|
1909
1913
|
if (this.activeRelease?.releaseId !== transition.previousReleaseId) {
|
|
1910
1914
|
throw new Error(`Previous release ${transition.previousReleaseId} is not the authoritative proxy target`)
|
|
1911
1915
|
}
|
|
1916
|
+
if (identity.acceptRetiredIncumbent) return await this.acceptRetiredIncumbentTransition(transition)
|
|
1912
1917
|
return await this.compensatePreCommitActivationFailure()
|
|
1913
1918
|
}
|
|
1914
1919
|
|
|
1920
|
+
/**
|
|
1921
|
+
* Stops an exact failed candidate and persists degraded incumbent web authority until cutover.
|
|
1922
|
+
* @param {GenerationTransition} transition - Exact failed transition proved by the caller.
|
|
1923
|
+
* @returns {Promise<Record<string, JsonValue>>} Explicit degraded recovery result.
|
|
1924
|
+
*/
|
|
1925
|
+
async acceptRetiredIncumbentTransition(transition) {
|
|
1926
|
+
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}`)
|
|
1927
|
+
if (!terminalRetirementFailure(transition.compensationError)) throw new Error("Retired-incumbent recovery requires a recorded restoration failure compatible with terminal retirement")
|
|
1928
|
+
if (!transition.previousReleaseId) throw new Error("Retired-incumbent recovery requires a retained previous release")
|
|
1929
|
+
const candidate = this.releases.get(transition.candidateReleaseId)
|
|
1930
|
+
const previous = this.releases.get(transition.previousReleaseId)
|
|
1931
|
+
|
|
1932
|
+
if (!candidate) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
|
|
1933
|
+
if (!previous) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
|
|
1934
|
+
if (candidate.releasePath !== transition.candidateReleasePath || candidate.revision !== transition.candidateRevision || ownerConfigDigest(candidate.config) !== transition.configDigest) {
|
|
1935
|
+
throw new Error(`Generation transition candidate ${candidate.releaseId} does not retain its exact path, revision, and config authority`)
|
|
1936
|
+
}
|
|
1937
|
+
if (candidate.state !== "draining" && candidate.state !== "stopped") throw new Error(`Failed candidate ${candidate.releaseId} must be retired before accepting a retired incumbent`)
|
|
1938
|
+
if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
|
|
1939
|
+
const activationConfig = previous.config.processes.find((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
|
|
1940
|
+
const coordinator = activationConfig ? previous.getProcesses(activationConfig.id)[0]?.process : undefined
|
|
1941
|
+
if (!coordinator || coordinator.status().lifecycleRole !== "retired") throw new Error(`Previous release ${previous.releaseId} does not retain a retired generation coordinator`)
|
|
1942
|
+
if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
|
|
1943
|
+
await candidate.drainAndStop(candidate.config.proxy.drainTimeoutMs, candidate.config)
|
|
1944
|
+
const candidateProcesses = candidate.status().processes
|
|
1945
|
+
|
|
1946
|
+
if (candidate.state !== "stopped" || candidateProcesses.some(({pid, state}) => pid !== undefined || (state !== "stopped" && state !== "failed"))) {
|
|
1947
|
+
throw new Error(`Failed candidate ${candidate.releaseId} did not fully stop during retired-incumbent recovery`)
|
|
1948
|
+
}
|
|
1949
|
+
if (this.activeRelease !== previous || !releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} lost live proxy/web authority during retired-incumbent recovery`)
|
|
1950
|
+
const result = /** @type {Record<string, JsonValue>} */ ({
|
|
1951
|
+
activeReleaseId: previous.releaseId,
|
|
1952
|
+
candidateReleaseId: candidate.releaseId,
|
|
1953
|
+
failedCandidateStatus: candidate.state,
|
|
1954
|
+
jobsStatus: "degraded",
|
|
1955
|
+
previousReleaseId: previous.releaseId,
|
|
1956
|
+
recoveryStatus: "retired_incumbent_accepted"
|
|
1957
|
+
})
|
|
1958
|
+
|
|
1959
|
+
if (transition.phase === "degraded_active") return result
|
|
1960
|
+
const previousError = transition.error
|
|
1961
|
+
const previousJournalRevision = transition.journalRevision
|
|
1962
|
+
const previousUpdatedAt = transition.updatedAt
|
|
1963
|
+
|
|
1964
|
+
transition.phase = "degraded_active"
|
|
1965
|
+
transition.error = undefined
|
|
1966
|
+
transition.journalRevision = (transition.journalRevision ?? 0) + 1
|
|
1967
|
+
transition.updatedAt = new Date().toISOString()
|
|
1968
|
+
try {
|
|
1969
|
+
await this.checkpointGenerationTransition()
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
transition.phase = "restoring_previous"
|
|
1972
|
+
transition.error = previousError
|
|
1973
|
+
transition.journalRevision = previousJournalRevision
|
|
1974
|
+
transition.updatedAt = previousUpdatedAt
|
|
1975
|
+
try {
|
|
1976
|
+
await this.publishOwnerState()
|
|
1977
|
+
} catch (publishError) {
|
|
1978
|
+
throw new AggregateError([error, publishError], "retired-incumbent recovery checkpoint and fence restoration failed", {cause: publishError})
|
|
1979
|
+
}
|
|
1980
|
+
throw new Error(`retired-incumbent recovery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`, {cause: error})
|
|
1981
|
+
}
|
|
1982
|
+
this.logger("retired incumbent accepted as jobs-degraded", result)
|
|
1983
|
+
return result
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1915
1986
|
/**
|
|
1916
1987
|
* @param {GenerationTransition} transition - Pending or committed exact transition.
|
|
1917
1988
|
* @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
|
|
@@ -2865,6 +2936,54 @@ export function ownerConfigDigest(config) {
|
|
|
2865
2936
|
return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex")
|
|
2866
2937
|
}
|
|
2867
2938
|
|
|
2939
|
+
/**
|
|
2940
|
+
* @param {PrivateOwnerState} transfer - Authenticated private incumbent state.
|
|
2941
|
+
* @param {GenerationTransition} transition - Unresolved generation transition.
|
|
2942
|
+
* @param {string} replacementConfigDigest - Proposed replacement authority.
|
|
2943
|
+
* @returns {boolean} Whether an existing exact config authority admits replacement.
|
|
2944
|
+
*/
|
|
2945
|
+
export function ownerReplacementTransitionAuthorityMatches(transfer, transition, replacementConfigDigest) {
|
|
2946
|
+
const retainedRelease = transfer.snapshot.releases.find((release) =>
|
|
2947
|
+
release.releaseId === transition.candidateReleaseId &&
|
|
2948
|
+
release.releasePath === transition.candidateReleasePath &&
|
|
2949
|
+
release.revision === transition.candidateRevision)
|
|
2950
|
+
const retainedConfig = transfer.releaseConfigs?.[transition.candidateReleaseId]
|
|
2951
|
+
const retainedDigest = retainedRelease && retainedConfig ? ownerConfigDigest(retainedConfig) : undefined
|
|
2952
|
+
|
|
2953
|
+
return transition.configDigest === replacementConfigDigest ||
|
|
2954
|
+
transition.configDigest === ownerConfigDigest(transfer.config) ||
|
|
2955
|
+
transition.configDigest === retainedDigest
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* @param {string | undefined} failure - Recorded incumbent restoration failure.
|
|
2960
|
+
* @returns {boolean} Whether the diagnostic can represent terminal external retirement.
|
|
2961
|
+
*/
|
|
2962
|
+
function terminalRetirementFailure(failure) {
|
|
2963
|
+
return typeof failure === "string" && (
|
|
2964
|
+
/^Cannot activate .+ generation from retired$/iu.test(failure) ||
|
|
2965
|
+
/^activate command exited non-zero with status \d+$/u.test(failure)
|
|
2966
|
+
)
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
/**
|
|
2970
|
+
* @param {ReleaseGroup} release - Expected authoritative release.
|
|
2971
|
+
* @returns {boolean} Whether every configured proxy process is live.
|
|
2972
|
+
*/
|
|
2973
|
+
function releaseOwnsLiveProxyTraffic(release) {
|
|
2974
|
+
const proxiedConfigs = release.config.processes.filter((processConfig) => processConfig.policy === "proxied")
|
|
2975
|
+
|
|
2976
|
+
return proxiedConfigs.length > 0 && proxiedConfigs.every((processConfig) => {
|
|
2977
|
+
const instances = release.getProcesses(processConfig.id)
|
|
2978
|
+
|
|
2979
|
+
return instances.length === processConfig.replicas && instances.every(({process}) => {
|
|
2980
|
+
const {pid, state} = process.status()
|
|
2981
|
+
|
|
2982
|
+
return pid !== undefined && state === "running"
|
|
2983
|
+
})
|
|
2984
|
+
})
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2868
2987
|
/**
|
|
2869
2988
|
* Accepts only the two exact authenticated diagnostics emitted by pre-split guardians.
|
|
2870
2989
|
* @param {string} diagnostic - Guardian response diagnostic.
|
package/test/completion.test.js
CHANGED
|
@@ -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")
|
package/test/rollbridge.test.js
CHANGED
|
@@ -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", "activate:v3"], "fresh deployment must not re-retire a degraded incumbent generation")
|
|
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)
|