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.
- 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 +126 -8
- 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,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
|
|
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.
|
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", "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)
|