rollbridge 0.1.36 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +7 -0
- package/docs/cli.md +2 -1
- package/docs/config.md +4 -2
- package/docs/troubleshooting.md +3 -0
- package/package.json +1 -1
- package/src/daemon.js +216 -61
- package/src/guardian-client.js +66 -1
- package/src/process-guardian.js +31 -6
- package/test/fixtures/partial-owner-replacement-process-guardian.js +106 -0
- package/test/guardian-client.test.js +74 -0
- package/test/owner-replacement.test.js +519 -2
package/README.md
CHANGED
|
@@ -240,9 +240,11 @@ later deploys continue to honor the original drain boundary.
|
|
|
240
240
|
|
|
241
241
|
There is one explicit compatibility boundary: the first upgrade from a genuine
|
|
242
242
|
pre-owner-replacement Rollbridge guardian and daemon cannot share its listeners
|
|
243
|
-
on supported Node 20.
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
on supported Node 20. The same boundary applies to a retained transition guardian
|
|
244
|
+
that supports prepare/stage but predates the retired-owner commit command. After
|
|
245
|
+
an explicit capability probe and authentication of the guardian, exact daemon
|
|
246
|
+
PID/socket, runtime authority, and durable owned-process state, `ensure-daemon`
|
|
247
|
+
performs a one-time **disruptive** bridge. Existing proxy/control connections may close,
|
|
246
248
|
the retained processes keep their exact PIDs under guardian supervision, and
|
|
247
249
|
`status.ownerTransition` reports `mode: "legacy-first-upgrade"` with
|
|
248
250
|
`disruptive: true`. The bridge requires the existing config identity unchanged;
|
|
@@ -7,3 +7,10 @@
|
|
|
7
7
|
- Fail closed when an older retained guardian cannot commit that replacement
|
|
8
8
|
atomically after the incumbent control socket disappears, preserving the
|
|
9
9
|
incumbent owner, retained connections, and guardian-managed release processes.
|
|
10
|
+
- Explicitly classify retained guardian replacement capabilities before preparing
|
|
11
|
+
a transaction. Guardians with prepare/stage support but no retired-owner commit
|
|
12
|
+
command use the fully attested one-time disruptive legacy upgrade bridge;
|
|
13
|
+
malformed, stale, or ambiguous protocol responses continue to fail closed. The
|
|
14
|
+
partial guardian's prepared transaction remains the mutation fence through
|
|
15
|
+
candidate reconstruction and boundary revalidation, and failed preparation
|
|
16
|
+
notifies the incumbent to resume paused release drains.
|
package/docs/cli.md
CHANGED
|
@@ -103,7 +103,8 @@ the candidate binds and validates its listeners, and an authenticated fenced
|
|
|
103
103
|
transaction commits guardian authority and the final control socket. A lost
|
|
104
104
|
control response is accepted only when the guardian confirms the exact committed
|
|
105
105
|
transaction id. The first authenticated upgrade from a genuine pre-replacement
|
|
106
|
-
guardian/daemon
|
|
106
|
+
guardian/daemon, or from a retained prepare/stage guardian that explicitly lacks
|
|
107
|
+
the retired-owner commit capability, is the sole exception: `ensure-daemon` preserves its exact
|
|
107
108
|
guardian-owned processes but deliberately retires the old listeners before the
|
|
108
109
|
Node 20 candidate binds, so existing proxy/control connections may close.
|
|
109
110
|
Successful status JSON records this as `ownerTransition.disruptive: true` and
|
package/docs/config.md
CHANGED
|
@@ -151,8 +151,10 @@ config reloads remain unchanged.
|
|
|
151
151
|
|
|
152
152
|
The first upgrade from an authenticated pre-replacement Rollbridge guardian and
|
|
153
153
|
daemon uses an explicitly disruptive compatibility bridge because that legacy
|
|
154
|
-
owner cannot transfer listeners on Node 20.
|
|
155
|
-
|
|
154
|
+
owner cannot transfer listeners on Node 20. A retained guardian that supports
|
|
155
|
+
replacement prepare/stage but lacks the retired-owner commit command is explicitly
|
|
156
|
+
classified and uses the same bridge. Rollbridge attests the exact guardian and
|
|
157
|
+
daemon processes, sockets, runtime/config authority, and durable process
|
|
156
158
|
registrations before retiring the legacy listeners. Managed process PIDs and
|
|
157
159
|
release state remain supervised, but live proxy/control connections may close.
|
|
158
160
|
The resulting status includes `ownerTransition: {disruptive: true, mode:
|
package/docs/troubleshooting.md
CHANGED
|
@@ -11,6 +11,9 @@ cannot authenticate and attest an allowed owner transition.
|
|
|
11
11
|
|
|
12
12
|
**Fix.** With `ownerRecovery`, a genuine pre-owner-replacement Rollbridge daemon
|
|
13
13
|
and guardian can cross the documented one-time disruptive bridge automatically.
|
|
14
|
+
A retained guardian that implements replacement prepare/stage but lacks the
|
|
15
|
+
retired-owner commit capability uses that same bridge only after the exact
|
|
16
|
+
non-mutating capability signature and legacy process dispatch are confirmed.
|
|
14
17
|
Its existing proxy/control connections may close; successful status reports
|
|
15
18
|
`ownerTransition.mode: "legacy-first-upgrade"`. Keep the incumbent config
|
|
16
19
|
identity unchanged for that first invocation, then apply config/socket changes
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -29,7 +29,7 @@ const STATE_PERSIST_INTERVAL_MS = 5000
|
|
|
29
29
|
* @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
|
|
30
30
|
* @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
|
|
31
31
|
* @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
|
|
32
|
-
* @typedef {{boundaryCrossed: boolean, incumbentControl
|
|
32
|
+
* @typedef {{boundaryCrossed: boolean, incumbentControl?: Awaited<ReturnType<typeof openControlSession>>, incumbentStartTime: string, legacyGuardian: GuardianClient, legacyInventory: {key: string, provenance: string}[], legacyPrepared?: {ownerState: JsonValue, replacementId: string}, legacySnapshot: OwnerRecoverySnapshot, prepared: {ownerState: JsonValue, replacementId: string}, recoverySnapshot: OwnerRecoverySnapshot}} LegacyOwnerBridge
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
export default class RollbridgeDaemon {
|
|
@@ -325,29 +325,38 @@ export default class RollbridgeDaemon {
|
|
|
325
325
|
configDigest: persisted.recovery.configDigest,
|
|
326
326
|
runtime: persisted.daemonRuntime ? {...persisted.daemonRuntime} : null
|
|
327
327
|
}
|
|
328
|
-
|
|
329
|
-
|
|
328
|
+
const replacementProtocol = await this.guardian.ownerReplacementProtocol()
|
|
329
|
+
const legacyBridge = replacementProtocol === "legacy"
|
|
330
|
+
? await this.prepareLegacyOwnerReplacement({persisted, persistedAuthority})
|
|
331
|
+
: undefined
|
|
332
|
+
const prepared = legacyBridge?.prepared ?? await this.guardian.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
|
|
333
|
+
let preparedStatus
|
|
334
|
+
let reservedProcessKey
|
|
335
|
+
let transfer
|
|
330
336
|
|
|
331
337
|
try {
|
|
332
|
-
|
|
338
|
+
preparedStatus = await this.guardian.replacementStatus()
|
|
339
|
+
transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
|
|
340
|
+
if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
|
|
341
|
+
const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
|
|
342
|
+
|
|
343
|
+
reservedProcessKey = legacyBridge ? undefined : ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
|
|
344
|
+
.find((key) => registeredProcesses.has(key))
|
|
345
|
+
if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
|
|
346
|
+
await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
|
|
333
347
|
} catch (error) {
|
|
334
|
-
legacyBridge
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
348
|
+
legacyBridge?.incumbentControl?.close()
|
|
349
|
+
if (legacyBridge) {
|
|
350
|
+
try {
|
|
351
|
+
await this.abandonLegacyOwnerBridge(legacyBridge)
|
|
352
|
+
} catch (cleanupError) {
|
|
353
|
+
throw new AggregateError([error, cleanupError], "Legacy owner replacement reconstruction failed and its upgrade coordinator could not be abandoned", {cause: cleanupError})
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
this.guardian.disconnect()
|
|
357
|
+
}
|
|
358
|
+
throw error
|
|
340
359
|
}
|
|
341
|
-
const preparedStatus = await this.guardian.replacementStatus()
|
|
342
|
-
const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
|
|
343
|
-
|
|
344
|
-
if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
|
|
345
|
-
const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
|
|
346
|
-
const reservedProcessKey = legacyBridge ? undefined : ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
|
|
347
|
-
.find((key) => registeredProcesses.has(key))
|
|
348
|
-
|
|
349
|
-
if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
|
|
350
|
-
await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
|
|
351
360
|
for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
|
|
352
361
|
this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
|
|
353
362
|
|
|
@@ -495,12 +504,13 @@ export default class RollbridgeDaemon {
|
|
|
495
504
|
}
|
|
496
505
|
if (legacyBridge && !legacyBridge.boundaryCrossed) {
|
|
497
506
|
try {
|
|
498
|
-
await this.
|
|
507
|
+
await this.abandonLegacyOwnerBridge(legacyBridge)
|
|
499
508
|
} catch (failure) {
|
|
500
509
|
abortError = failure instanceof Error ? failure : new Error(String(failure))
|
|
501
510
|
}
|
|
502
511
|
} else {
|
|
503
512
|
this.guardian.disconnect()
|
|
513
|
+
legacyBridge?.legacyGuardian.disconnect()
|
|
504
514
|
}
|
|
505
515
|
if (committed) {
|
|
506
516
|
const commitmentError = await committed
|
|
@@ -551,56 +561,74 @@ export default class RollbridgeDaemon {
|
|
|
551
561
|
}
|
|
552
562
|
|
|
553
563
|
/**
|
|
554
|
-
* Authenticates and prepares the one-time disruptive bridge for
|
|
555
|
-
* @param {{
|
|
564
|
+
* Authenticates and prepares the one-time disruptive bridge for an exact legacy guardian protocol.
|
|
565
|
+
* @param {{persisted: OwnerRecoverySnapshot, persistedAuthority: {configDigest: string, runtime: import("./daemon-runtime.js").DaemonRuntimeIdentity | null}}} options - Legacy evidence.
|
|
556
566
|
* @returns {Promise<LegacyOwnerBridge>} Prepared bridge.
|
|
557
567
|
*/
|
|
558
|
-
async prepareLegacyOwnerReplacement({
|
|
559
|
-
const diagnostic = error instanceof Error ? error.message : String(error)
|
|
568
|
+
async prepareLegacyOwnerReplacement({persisted, persistedAuthority}) {
|
|
560
569
|
const legacyProcessKey = ownerSnapshotProcessKeys(persisted, persisted.singletonReleaseIds)[0]
|
|
570
|
+
const legacyGuardian = this.guardian
|
|
571
|
+
let legacyPrepared
|
|
572
|
+
|
|
573
|
+
if (!legacyProcessKey) throw new Error("Legacy disruptive owner replacement requires an exact guardian-owned process registration in durable state")
|
|
574
|
+
if (!legacyGuardian) throw new Error("Legacy disruptive replacement is missing its authenticated guardian connection")
|
|
575
|
+
try {
|
|
576
|
+
legacyPrepared = await legacyGuardian.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
|
|
577
|
+
} catch (error) {
|
|
578
|
+
const diagnostic = error instanceof Error ? error.message : String(error)
|
|
579
|
+
|
|
580
|
+
if (!isLegacyGuardianPrepareDiagnostic(diagnostic)) throw error
|
|
581
|
+
let probeAccepted = false
|
|
561
582
|
|
|
562
|
-
if (!isLegacyGuardianPrepareDiagnostic(diagnostic)) throw error
|
|
563
|
-
if (!legacyProcessKey) throw new Error("Legacy disruptive owner replacement requires an exact guardian-owned process registration in durable state", {cause: error})
|
|
564
|
-
if (diagnostic !== "Unknown guardian command: prepare-owner-replacement") {
|
|
565
583
|
try {
|
|
566
|
-
await
|
|
584
|
+
await legacyGuardian.request({
|
|
567
585
|
authority: persistedAuthority,
|
|
568
586
|
command: "prepare-owner-replacement",
|
|
569
587
|
key: legacyProcessKey,
|
|
570
588
|
nextAuthority: this.ownerAuthority()
|
|
571
589
|
})
|
|
572
|
-
|
|
590
|
+
probeAccepted = true
|
|
573
591
|
} catch (probeError) {
|
|
574
592
|
if (!(probeError instanceof Error) || probeError.message !== "Unknown guardian command: prepare-owner-replacement") {
|
|
575
593
|
throw new Error("Guardian does not match the authenticated pre-split replacement protocol signature", {cause: probeError})
|
|
576
594
|
}
|
|
577
595
|
}
|
|
596
|
+
if (probeAccepted) throw new Error("Legacy guardian protocol probe unexpectedly accepted owner replacement", {cause: error})
|
|
578
597
|
}
|
|
579
|
-
|
|
580
|
-
throw new Error("The one-time legacy guardian bridge requires the incumbent config identity unchanged; retry the config change after the bridge establishes split-3 authority")
|
|
581
|
-
}
|
|
582
|
-
if (!this.legacyIncumbentPid) throw new Error("The authenticated pre-split guardian requires an exact incumbent PID from ensure-daemon for the disruptive bridge")
|
|
583
|
-
if (!this.guardianIdentity?.pid) throw new Error("The authenticated pre-split guardian state is missing its exact guardian PID")
|
|
584
|
-
const legacyGuardian = this.guardian
|
|
585
|
-
|
|
586
|
-
if (!legacyGuardian) throw new Error("Legacy disruptive replacement is missing its authenticated guardian connection")
|
|
587
|
-
|
|
588
|
-
await verifyLegacyGuardianProcess(this.guardianIdentity.pid, this.guardianIdentity.socketPath)
|
|
589
|
-
const incumbentStartTime = await verifyLegacyDaemonProcess(this.legacyIncumbentPid, this.configPath, persisted.control.path)
|
|
590
|
-
const incumbentControl = await openControlSession(persisted.control.path)
|
|
598
|
+
let incumbentControl
|
|
591
599
|
let upgraded
|
|
592
600
|
|
|
593
601
|
try {
|
|
594
|
-
|
|
602
|
+
if (persisted.recovery.configDigest !== this.ownerRecoveryConfigDigest()) {
|
|
603
|
+
throw new Error("The one-time legacy guardian bridge requires the incumbent config identity unchanged; retry the config change after the bridge establishes split-3 authority")
|
|
604
|
+
}
|
|
605
|
+
if (!this.legacyIncumbentPid) throw new Error("The authenticated legacy guardian requires an exact incumbent PID from ensure-daemon for the disruptive bridge")
|
|
606
|
+
if (!this.guardianIdentity?.pid) throw new Error("The authenticated legacy guardian state is missing its exact guardian PID")
|
|
607
|
+
await verifyLegacyGuardianProcess(this.guardianIdentity.pid, this.guardianIdentity.socketPath)
|
|
608
|
+
const incumbentStartTime = await verifyLegacyDaemonProcess(this.legacyIncumbentPid, this.configPath, persisted.control.path)
|
|
609
|
+
if (legacyPrepared) assertLegacyPrivateOwnerState(legacyPrepared.ownerState, persisted, persistedAuthority)
|
|
610
|
+
try {
|
|
611
|
+
incumbentControl = await openControlSession(persisted.control.path)
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (!legacyPrepared || !error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
614
|
+
}
|
|
615
|
+
if (incumbentControl) {
|
|
616
|
+
const incumbentStatus = await incumbentControl.request({command: "status"})
|
|
595
617
|
|
|
596
|
-
|
|
597
|
-
const ownerState = {
|
|
598
|
-
authority: persistedAuthority,
|
|
599
|
-
config: this.config,
|
|
600
|
-
releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
|
|
601
|
-
singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
|
|
602
|
-
snapshot: persisted
|
|
618
|
+
assertLegacyIncumbentStatus(incumbentStatus, persisted)
|
|
603
619
|
}
|
|
620
|
+
const ownerState = legacyPrepared
|
|
621
|
+
? legacyPrepared.ownerState
|
|
622
|
+
: {
|
|
623
|
+
authority: persistedAuthority,
|
|
624
|
+
config: this.config,
|
|
625
|
+
releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
|
|
626
|
+
singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
|
|
627
|
+
snapshot: persisted
|
|
628
|
+
}
|
|
629
|
+
const legacyInventory = normalizeLegacyGuardianInventory(await legacyGuardian.inventory())
|
|
630
|
+
|
|
631
|
+
assertLegacyGuardianInventoryMembership(legacyInventory, /** @type {PrivateOwnerState} */ (ownerState))
|
|
604
632
|
const upgradedIdentity = /** @type {{pid?: number, socketPath: string, token: string}} */ ({
|
|
605
633
|
socketPath: `${this.statePath}.split3-guardian.sock`,
|
|
606
634
|
token: crypto.randomBytes(32).toString("hex")
|
|
@@ -609,7 +637,6 @@ export default class RollbridgeDaemon {
|
|
|
609
637
|
await assertPathAbsent(upgradedIdentity.socketPath, "Legacy upgrade guardian socket")
|
|
610
638
|
upgraded = await legacyGuardian.upgradeLegacyGuardian({ownerState, ...upgradedIdentity})
|
|
611
639
|
upgradedIdentity.pid = upgraded.pid
|
|
612
|
-
legacyGuardian.disconnect()
|
|
613
640
|
this.guardian = upgraded
|
|
614
641
|
this.guardianIdentity = upgradedIdentity
|
|
615
642
|
const prepared = await upgraded.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
|
|
@@ -623,36 +650,102 @@ export default class RollbridgeDaemon {
|
|
|
623
650
|
incumbentPid: this.legacyIncumbentPid,
|
|
624
651
|
replacementId: prepared.replacementId
|
|
625
652
|
})
|
|
626
|
-
return {boundaryCrossed: false, incumbentControl, incumbentStartTime, prepared, recoverySnapshot}
|
|
653
|
+
return {boundaryCrossed: false, incumbentControl, incumbentStartTime, legacyGuardian, legacyInventory, legacyPrepared, legacySnapshot: persisted, prepared, recoverySnapshot}
|
|
627
654
|
} catch (upgradeError) {
|
|
628
|
-
incumbentControl
|
|
629
|
-
|
|
655
|
+
incumbentControl?.close()
|
|
656
|
+
let cleanupError
|
|
657
|
+
|
|
658
|
+
if (upgraded) {
|
|
659
|
+
try {
|
|
660
|
+
await upgraded.abandonLegacyUpgrade()
|
|
661
|
+
} catch (error) {
|
|
662
|
+
cleanupError = error instanceof Error ? error : new Error(String(error))
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
legacyGuardian.disconnect()
|
|
666
|
+
if (cleanupError) {
|
|
667
|
+
throw new AggregateError([upgradeError, cleanupError], "Legacy owner replacement validation failed and its upgrade coordinator could not be abandoned", {cause: upgradeError})
|
|
668
|
+
}
|
|
630
669
|
throw upgradeError
|
|
631
670
|
}
|
|
632
671
|
}
|
|
633
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Abandons the uncommitted coordinator before releasing the incumbent guardian transaction.
|
|
675
|
+
* @param {LegacyOwnerBridge} bridge - Prepared bridge.
|
|
676
|
+
*/
|
|
677
|
+
async abandonLegacyOwnerBridge(bridge) {
|
|
678
|
+
let cleanupError
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
await this.guardian?.abandonLegacyUpgrade()
|
|
682
|
+
} catch (error) {
|
|
683
|
+
cleanupError = error instanceof Error ? error : new Error(String(error))
|
|
684
|
+
}
|
|
685
|
+
bridge.legacyGuardian.disconnect()
|
|
686
|
+
if (cleanupError) throw cleanupError
|
|
687
|
+
}
|
|
688
|
+
|
|
634
689
|
/**
|
|
635
690
|
* Crosses the explicitly disruptive legacy-only boundary after candidate reconstruction.
|
|
636
691
|
* @param {LegacyOwnerBridge} bridge - Prepared bridge.
|
|
637
692
|
*/
|
|
638
693
|
async crossLegacyDisruptiveBoundary(bridge) {
|
|
639
694
|
if (!this.legacyIncumbentPid || !this.statePath) throw new Error("Legacy disruptive boundary is missing its exact incumbent identity")
|
|
695
|
+
if (!this.guardian) throw new Error("Legacy disruptive boundary is missing its authenticated upgraded guardian")
|
|
696
|
+
if (!bridge.legacyGuardian.pid) throw new Error("Legacy disruptive boundary is missing its authenticated retained guardian PID")
|
|
697
|
+
await verifyLegacyGuardianProcess(bridge.legacyGuardian.pid, bridge.legacyGuardian.socketPath)
|
|
640
698
|
const currentStartTime = await verifyLegacyDaemonProcess(this.legacyIncumbentPid, this.configPath, bridge.recoverySnapshot.control.path)
|
|
641
699
|
|
|
642
700
|
if (currentStartTime !== bridge.incumbentStartTime) throw new Error("Legacy incumbent PID identity changed before the disruptive boundary")
|
|
701
|
+
if (!isDeepStrictEqual(await readState(this.statePath), bridge.legacySnapshot)) {
|
|
702
|
+
throw new Error("Legacy owner recovery state changed before the disruptive boundary")
|
|
703
|
+
}
|
|
704
|
+
if (bridge.incumbentControl) {
|
|
705
|
+
assertLegacyIncumbentStatus(await bridge.incumbentControl.request({command: "status"}), bridge.legacySnapshot)
|
|
706
|
+
}
|
|
707
|
+
const currentInventory = normalizeLegacyGuardianInventory(await bridge.legacyGuardian.inventory())
|
|
708
|
+
|
|
709
|
+
if (!isDeepStrictEqual(currentInventory, bridge.legacyInventory)) {
|
|
710
|
+
throw new Error("Legacy guardian process inventory changed before the disruptive boundary")
|
|
711
|
+
}
|
|
712
|
+
const candidateOwnerState = {
|
|
713
|
+
authority: this.ownerAuthority(),
|
|
714
|
+
config: this.config,
|
|
715
|
+
releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
|
|
716
|
+
singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
|
|
717
|
+
snapshot: this.status()
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
assertLegacyGuardianInventoryMembership(currentInventory, candidateOwnerState)
|
|
721
|
+
let legacyCommitted
|
|
722
|
+
|
|
723
|
+
if (bridge.legacyPrepared) {
|
|
724
|
+
const status = await bridge.legacyGuardian.replacementStatus()
|
|
725
|
+
|
|
726
|
+
if (!status.ownerClaimed) throw new Error("Legacy guardian incumbent ownership changed before the disruptive boundary")
|
|
727
|
+
legacyCommitted = bridge.legacyGuardian.waitForEvent("replacement-committed")
|
|
728
|
+
const staged = await bridge.legacyGuardian.stageOwnerReplacement(bridge.legacyPrepared.replacementId, candidateOwnerState)
|
|
729
|
+
|
|
730
|
+
if (staged.committed) throw new Error("Legacy guardian committed replacement before the authenticated disruptive boundary")
|
|
731
|
+
}
|
|
643
732
|
this.logger("legacy owner replacement disruptive boundary", {
|
|
644
733
|
disruptive: true,
|
|
645
734
|
incumbentPid: this.legacyIncumbentPid,
|
|
646
|
-
reason: "
|
|
735
|
+
reason: "retained guardian and daemon lacked atomic replacement protocol"
|
|
647
736
|
})
|
|
737
|
+
await this.guardian.beginLegacyOwnerClaim(bridge.prepared.replacementId, bridge.recoverySnapshot.recovery.reconnectGraceMs)
|
|
648
738
|
process.kill(this.legacyIncumbentPid, "SIGKILL")
|
|
649
739
|
bridge.boundaryCrossed = true
|
|
650
|
-
await bridge.incumbentControl
|
|
740
|
+
await bridge.incumbentControl?.closed()
|
|
741
|
+
if (legacyCommitted) await legacyCommitted
|
|
742
|
+
bridge.legacyGuardian.disconnect()
|
|
743
|
+
await this.guardian.completeLegacyOwnerClaim(bridge.prepared.replacementId)
|
|
651
744
|
await writeState(this.statePath, bridge.recoverySnapshot)
|
|
652
745
|
this.ownerTransition = /** @type {OwnerTransition} */ ({
|
|
653
746
|
disruptive: true,
|
|
654
747
|
mode: "legacy-first-upgrade",
|
|
655
|
-
reason: "
|
|
748
|
+
reason: "retained guardian and daemon lacked atomic replacement protocol"
|
|
656
749
|
})
|
|
657
750
|
}
|
|
658
751
|
|
|
@@ -2009,7 +2102,7 @@ export function isLegacyGuardianPrepareDiagnostic(diagnostic) {
|
|
|
2009
2102
|
}
|
|
2010
2103
|
|
|
2011
2104
|
/**
|
|
2012
|
-
* @param {
|
|
2105
|
+
* @param {DaemonStatus} snapshot - Serialized owner process snapshot.
|
|
2013
2106
|
* @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
|
|
2014
2107
|
* @returns {string[]} Exact guardian registration keys present in the snapshot.
|
|
2015
2108
|
*/
|
|
@@ -2028,6 +2121,40 @@ function ownerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
|
|
|
2028
2121
|
return keys
|
|
2029
2122
|
}
|
|
2030
2123
|
|
|
2124
|
+
/**
|
|
2125
|
+
* Canonicalizes the authenticated guardian inventory without relying on map insertion order.
|
|
2126
|
+
* @param {{key: string, provenance: string}[]} inventory - Guardian inventory response.
|
|
2127
|
+
* @returns {{key: string, provenance: string}[]} Exact key/provenance fence.
|
|
2128
|
+
*/
|
|
2129
|
+
function normalizeLegacyGuardianInventory(inventory) {
|
|
2130
|
+
const normalized = inventory.map((entry) => {
|
|
2131
|
+
if (!entry || typeof entry.key !== "string" || !entry.key || typeof entry.provenance !== "string" || !entry.provenance) {
|
|
2132
|
+
throw new Error("Legacy guardian returned an invalid process inventory")
|
|
2133
|
+
}
|
|
2134
|
+
return {key: entry.key, provenance: entry.provenance}
|
|
2135
|
+
}).sort((left, right) => left.key.localeCompare(right.key))
|
|
2136
|
+
|
|
2137
|
+
if (new Set(normalized.map(({key}) => key)).size !== normalized.length) {
|
|
2138
|
+
throw new Error("Legacy guardian returned duplicate process registrations")
|
|
2139
|
+
}
|
|
2140
|
+
return normalized
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* Requires every and only the process registrations serialized by committed private owner state.
|
|
2145
|
+
* @param {{key: string, provenance: string}[]} inventory - Canonical guardian inventory.
|
|
2146
|
+
* @param {{snapshot: DaemonStatus, singletonReleaseIds?: Record<string, string>}} ownerState - Authenticated owner process snapshot.
|
|
2147
|
+
*/
|
|
2148
|
+
function assertLegacyGuardianInventoryMembership(inventory, ownerState) {
|
|
2149
|
+
if (!ownerState?.snapshot) throw new Error("Legacy guardian owner state is missing its committed process snapshot")
|
|
2150
|
+
const inventoryKeys = inventory.map(({key}) => key)
|
|
2151
|
+
const snapshotKeys = ownerSnapshotProcessKeys(ownerState.snapshot, ownerState.singletonReleaseIds).sort((left, right) => left.localeCompare(right))
|
|
2152
|
+
|
|
2153
|
+
if (!isDeepStrictEqual(inventoryKeys, snapshotKeys)) {
|
|
2154
|
+
throw new Error("Legacy guardian process inventory does not match committed owner state")
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2031
2158
|
/**
|
|
2032
2159
|
* Verifies the exact authenticated guardian process and socket without scanning other PIDs.
|
|
2033
2160
|
* @param {number} pid - Persisted guardian PID.
|
|
@@ -2037,7 +2164,7 @@ async function verifyLegacyGuardianProcess(pid, socketPath) {
|
|
|
2037
2164
|
const args = await processArguments(pid, "legacy guardian")
|
|
2038
2165
|
const script = args.find((argument) => argument.endsWith("process-guardian.js"))
|
|
2039
2166
|
|
|
2040
|
-
if (!script || !args.includes(socketPath)) throw new Error(`Persisted guardian PID ${pid} does not match the
|
|
2167
|
+
if (!script || !args.includes(socketPath)) throw new Error(`Persisted guardian PID ${pid} does not match the retained guardian command and socket`)
|
|
2041
2168
|
await verifyProcessUser(pid, "legacy guardian")
|
|
2042
2169
|
await verifyUnixSocketOwner(pid, socketPath, "legacy guardian")
|
|
2043
2170
|
}
|
|
@@ -2056,7 +2183,7 @@ async function verifyLegacyDaemonProcess(pid, configPath, socketPath) {
|
|
|
2056
2183
|
const configIndex = args.indexOf("--config")
|
|
2057
2184
|
|
|
2058
2185
|
if (daemonIndex < 0 || configIndex < 0 || args[configIndex + 1] !== configPath) {
|
|
2059
|
-
throw new Error(`Daemon PID ${pid} does not match the exact
|
|
2186
|
+
throw new Error(`Daemon PID ${pid} does not match the exact retained daemon config command`)
|
|
2060
2187
|
}
|
|
2061
2188
|
await verifyProcessUser(pid, "legacy daemon")
|
|
2062
2189
|
await verifyUnixSocketOwner(pid, socketPath, "legacy daemon")
|
|
@@ -2132,10 +2259,38 @@ function assertLegacyIncumbentStatus(status, persisted) {
|
|
|
2132
2259
|
if (status.application !== persisted.application || !control || typeof control !== "object" || Array.isArray(control) || control.path !== persisted.control.path ||
|
|
2133
2260
|
!runtime || typeof runtime !== "object" || Array.isArray(runtime) || runtime.digest !== persisted.daemonRuntime?.digest ||
|
|
2134
2261
|
!ownerRecovery || typeof ownerRecovery !== "object" || Array.isArray(ownerRecovery) || ownerRecovery.configDigest !== persisted.recovery.configDigest) {
|
|
2135
|
-
throw new Error("Responsive incumbent does not match the exact persisted
|
|
2262
|
+
throw new Error("Responsive incumbent does not match the exact persisted retained-daemon authority")
|
|
2136
2263
|
}
|
|
2137
2264
|
}
|
|
2138
2265
|
|
|
2266
|
+
/**
|
|
2267
|
+
* Validates private committed state returned by a partial transaction guardian.
|
|
2268
|
+
* @param {JsonValue} value - Authenticated guardian owner state.
|
|
2269
|
+
* @param {OwnerRecoverySnapshot} persisted - Durable expected identity.
|
|
2270
|
+
* @param {{configDigest: string, runtime: import("./daemon-runtime.js").DaemonRuntimeIdentity | null}} persistedAuthority - Exact committed authority.
|
|
2271
|
+
*/
|
|
2272
|
+
function assertLegacyPrivateOwnerState(value, persisted, persistedAuthority) {
|
|
2273
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Partial legacy guardian returned invalid committed owner state")
|
|
2274
|
+
const state = /** @type {Record<string, JsonValue>} */ (value)
|
|
2275
|
+
const config = state.config
|
|
2276
|
+
const snapshot = state.snapshot
|
|
2277
|
+
|
|
2278
|
+
if (!isDeepStrictEqual(state.authority, persistedAuthority) || !config || typeof config !== "object" || Array.isArray(config) ||
|
|
2279
|
+
ownerConfigDigest(/** @type {import("./config.js").RollbridgeConfig} */ (config)) !== persistedAuthority.configDigest ||
|
|
2280
|
+
!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
2281
|
+
throw new Error("Partial legacy guardian committed owner state does not match the persisted authority")
|
|
2282
|
+
}
|
|
2283
|
+
const privateSnapshot = /** @type {OwnerRecoverySnapshot} */ (snapshot)
|
|
2284
|
+
|
|
2285
|
+
assertLegacyIncumbentStatus(/** @type {Record<string, JsonValue>} */ (snapshot), persisted)
|
|
2286
|
+
if (!isDeepStrictEqual(
|
|
2287
|
+
ownerSnapshotProcessKeys(privateSnapshot, state.singletonReleaseIds && typeof state.singletonReleaseIds === "object" && !Array.isArray(state.singletonReleaseIds)
|
|
2288
|
+
? /** @type {Record<string, string>} */ (state.singletonReleaseIds)
|
|
2289
|
+
: {}).sort(),
|
|
2290
|
+
ownerSnapshotProcessKeys(persisted, persisted.singletonReleaseIds).sort()
|
|
2291
|
+
)) throw new Error("Partial legacy guardian committed process membership does not match durable recovery state")
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2139
2294
|
/**
|
|
2140
2295
|
* @param {string} candidatePath - Path that must not preexist.
|
|
2141
2296
|
* @param {string} label - Diagnostic label.
|
package/src/guardian-client.js
CHANGED
|
@@ -54,7 +54,7 @@ export default class GuardianClient {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Starts a current transaction guardian in front of this authenticated
|
|
57
|
+
* Starts a current transaction guardian in front of this authenticated legacy guardian.
|
|
58
58
|
* @param {{ownerState: import("./json.js").JsonValue, socketPath: string, token: string}} options - Upgrade identity and exact committed state.
|
|
59
59
|
* @returns {Promise<GuardianClient>} Current guardian client backed by the legacy supervisor.
|
|
60
60
|
*/
|
|
@@ -189,6 +189,32 @@ export default class GuardianClient {
|
|
|
189
189
|
await this.request({command: "publish-owner-state", ownerState})
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Classifies the authenticated guardian's owner-replacement protocol without preparing a transaction.
|
|
194
|
+
* @returns {Promise<"atomic" | "legacy">} Compatible replacement route.
|
|
195
|
+
*/
|
|
196
|
+
async ownerReplacementProtocol() {
|
|
197
|
+
try {
|
|
198
|
+
const response = await this.request({command: "owner-replacement-capabilities"})
|
|
199
|
+
|
|
200
|
+
if (!isOwnerReplacementCapabilities(response)) throw new Error("Guardian returned an invalid owner-replacement capability response")
|
|
201
|
+
return "atomic"
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (!(error instanceof Error) || !isLegacyCapabilityDispatchDiagnostic(error.message)) throw error
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await this.request({command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"})
|
|
208
|
+
throw new Error("Guardian unexpectedly accepted the retired-owner capability probe")
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!(error instanceof Error)) throw error
|
|
211
|
+
if (error.message === "Owner replacement transaction is not the prepared candidate") return "atomic"
|
|
212
|
+
if (error.message === "Guardian commit-retired-owner-replacement requires a process key" ||
|
|
213
|
+
error.message === "Unknown guardian command: commit-retired-owner-replacement") return "legacy"
|
|
214
|
+
throw new Error("Guardian returned an ambiguous retired-owner capability response", {cause: error})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
/**
|
|
193
219
|
* @param {import("./json.js").JsonValue} authority - Persisted current authority.
|
|
194
220
|
* @param {import("./json.js").JsonValue} nextAuthority - Requested authority.
|
|
@@ -225,6 +251,23 @@ export default class GuardianClient {
|
|
|
225
251
|
await this.request({command: "commit-retired-owner-replacement", key, replacementId})
|
|
226
252
|
}
|
|
227
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Begins acquiring the authenticated legacy backend owner channel at the disruptive boundary.
|
|
256
|
+
* @param {string} replacementId - Exact prepared candidate transaction.
|
|
257
|
+
* @param {number} graceMs - Event-driven incumbent disconnect grace.
|
|
258
|
+
*/
|
|
259
|
+
async beginLegacyOwnerClaim(replacementId, graceMs) {
|
|
260
|
+
await this.request({command: "begin-legacy-owner-claim", graceMs, replacementId})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Waits until the upgraded guardian owns its authenticated legacy backend.
|
|
265
|
+
* @param {string} replacementId - Exact prepared candidate transaction.
|
|
266
|
+
*/
|
|
267
|
+
async completeLegacyOwnerClaim(replacementId) {
|
|
268
|
+
await this.request({command: "complete-legacy-owner-claim", replacementId})
|
|
269
|
+
}
|
|
270
|
+
|
|
228
271
|
/** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
|
|
229
272
|
async finalizeOwnerReplacement(replacementId) {
|
|
230
273
|
await this.request({command: "finalize-owner-replacement", replacementId})
|
|
@@ -496,6 +539,28 @@ function asProcessStatus(value) {
|
|
|
496
539
|
return JSON.parse(JSON.stringify(value))
|
|
497
540
|
}
|
|
498
541
|
|
|
542
|
+
/**
|
|
543
|
+
* @param {import("./json.js").JsonValue} value - Capability response.
|
|
544
|
+
* @returns {boolean} Whether the response explicitly guarantees retired-owner commit support.
|
|
545
|
+
*/
|
|
546
|
+
function isOwnerReplacementCapabilities(value) {
|
|
547
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
|
548
|
+
const response = /** @type {Record<string, import("./json.js").JsonValue>} */ (value)
|
|
549
|
+
|
|
550
|
+
return response.protocol === "owner-replacement" && Number.isInteger(response.version) && Number(response.version) >= 1 &&
|
|
551
|
+
Array.isArray(response.commands) && response.commands.every((command) => typeof command === "string") &&
|
|
552
|
+
response.commands.includes("commit-retired-owner-replacement")
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {string} diagnostic - Exact old generic-dispatch response.
|
|
557
|
+
* @returns {boolean} Whether this is the narrow old unknown-command dispatch signature.
|
|
558
|
+
*/
|
|
559
|
+
function isLegacyCapabilityDispatchDiagnostic(diagnostic) {
|
|
560
|
+
return diagnostic === "Guardian owner-replacement-capabilities requires a process key" ||
|
|
561
|
+
diagnostic === "Unknown guardian command: owner-replacement-capabilities"
|
|
562
|
+
}
|
|
563
|
+
|
|
499
564
|
/**
|
|
500
565
|
* @param {import("./json.js").JsonValue} value - Protocol value.
|
|
501
566
|
* @returns {import("./managed-process.js").ManagedProcessLog} Process output entry.
|
package/src/process-guardian.js
CHANGED
|
@@ -77,6 +77,8 @@ let ownerMutationId
|
|
|
77
77
|
let retiringClient
|
|
78
78
|
/** @type {string | undefined} */
|
|
79
79
|
let retiringReplacementId
|
|
80
|
+
/** @type {Promise<Error | undefined> | undefined} */
|
|
81
|
+
let legacyOwnerClaim
|
|
80
82
|
let shuttingDown = false
|
|
81
83
|
/** @type {net.Socket | undefined} */
|
|
82
84
|
let shutdownClient
|
|
@@ -117,11 +119,7 @@ const server = net.createServer((socket) => {
|
|
|
117
119
|
}
|
|
118
120
|
if (retiringClient === socket) finalizeReplacementRetirement()
|
|
119
121
|
if (replacementClient === socket) {
|
|
120
|
-
|
|
121
|
-
replacementId = undefined
|
|
122
|
-
replacementAuthority = undefined
|
|
123
|
-
replacementOwnerState = undefined
|
|
124
|
-
if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted"})}\n`)
|
|
122
|
+
abortReplacement("Replacement candidate disconnected before commit")
|
|
125
123
|
}
|
|
126
124
|
if (shutdownClient === socket) void finishShutdown()
|
|
127
125
|
})
|
|
@@ -180,6 +178,10 @@ async function handleLine(socket, line) {
|
|
|
180
178
|
async function execute(request, socket) {
|
|
181
179
|
if (shuttingDown) throw new Error("Process guardian is shutting down")
|
|
182
180
|
|
|
181
|
+
if (request.command === "owner-replacement-capabilities") {
|
|
182
|
+
return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
|
|
183
|
+
}
|
|
184
|
+
|
|
183
185
|
if (request.command === "claim-owner") {
|
|
184
186
|
if (!ownerClient) {
|
|
185
187
|
if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
|
|
@@ -225,6 +227,26 @@ async function execute(request, socket) {
|
|
|
225
227
|
return {abandoned: true}
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
if (request.command === "begin-legacy-owner-claim") {
|
|
231
|
+
if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
|
|
232
|
+
requireReplacement(socket, request)
|
|
233
|
+
if (legacyOwnerClaim) throw new Error("Legacy guardian owner claim is already pending")
|
|
234
|
+
legacyOwnerClaim = legacyGuardian.claimOwner(request.graceMs ?? 30000, ownerAuthority(ownerState)).then(
|
|
235
|
+
() => undefined,
|
|
236
|
+
(error) => error instanceof Error ? error : new Error(String(error))
|
|
237
|
+
)
|
|
238
|
+
return {prepared: true}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (request.command === "complete-legacy-owner-claim") {
|
|
242
|
+
if (!legacyGuardian || !legacyOwnerClaim) throw new Error("Legacy guardian owner claim was not prepared")
|
|
243
|
+
requireReplacement(socket, request)
|
|
244
|
+
const claimError = await legacyOwnerClaim
|
|
245
|
+
|
|
246
|
+
if (claimError) throw claimError
|
|
247
|
+
return {claimed: true}
|
|
248
|
+
}
|
|
249
|
+
|
|
228
250
|
if (request.command === "publish-owner-state") {
|
|
229
251
|
requireOwner(socket, request.command)
|
|
230
252
|
if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
|
|
@@ -470,7 +492,10 @@ function requireOwner(socket, command) {
|
|
|
470
492
|
|
|
471
493
|
/** @param {string} reason - Abort diagnostic. */
|
|
472
494
|
function abortReplacement(reason) {
|
|
473
|
-
|
|
495
|
+
const event = `${JSON.stringify({event: "replacement-aborted", reason})}\n`
|
|
496
|
+
|
|
497
|
+
if (replacementClient && !replacementClient.destroyed) replacementClient.write(event)
|
|
498
|
+
if (ownerClient && !ownerClient.destroyed) ownerClient.write(event)
|
|
474
499
|
replacementClient = undefined
|
|
475
500
|
replacementId = undefined
|
|
476
501
|
replacementAuthority = undefined
|