rollbridge 0.1.19 → 0.1.21
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 +9 -0
- package/docs/cli.md +8 -1
- package/package.json +1 -1
- package/src/cli.js +11 -3
- package/src/daemon.js +59 -5
- package/src/managed-process.js +28 -20
- package/src/release-group.js +5 -0
- package/test/daemon-bootstrap.test.js +32 -0
- package/test/rollbridge.test.js +8 -0
- package/test/shutdown-completion.test.js +37 -0
package/README.md
CHANGED
|
@@ -412,6 +412,15 @@ rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
|
412
412
|
--boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
413
413
|
```
|
|
414
414
|
|
|
415
|
+
External supervisors that need to replace a foreground owner without waiting
|
|
416
|
+
for its workers to drain add `--takeover-owner`. The candidate starts and
|
|
417
|
+
health-checks the exact release first. Only then does it retire the accepted
|
|
418
|
+
owner's proxy/control listeners; the retired daemon keeps its already-accepted
|
|
419
|
+
workers until their ordinary drain completes while the attested replacement
|
|
420
|
+
binds the stable listeners. Candidate bootstrap failure leaves the accepted
|
|
421
|
+
owner untouched. This is opt-in; ordinary daemon bootstrap and `shutdown` keep
|
|
422
|
+
their existing behavior.
|
|
423
|
+
|
|
415
424
|
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
416
425
|
paths. Rollbridge binds its proxy, activates the release through the normal
|
|
417
426
|
deploy path, then exposes the control socket and stays foreground. A failed
|
package/docs/cli.md
CHANGED
|
@@ -23,7 +23,7 @@ process-policy details.
|
|
|
23
23
|
## `daemon`
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
rollbridge daemon [--config <path>]
|
|
26
|
+
rollbridge daemon [--config <path>] [--takeover-owner]
|
|
27
27
|
[--release-path <path> --release-id <id> --revision <sha>
|
|
28
28
|
[--boot-attestation <sha256:digest>]]
|
|
29
29
|
```
|
|
@@ -64,6 +64,13 @@ identity. Rollbridge does not calculate or interpret the digest.
|
|
|
64
64
|
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
65
65
|
and waits for control-socket deployments.
|
|
66
66
|
|
|
67
|
+
`--takeover-owner` requires the complete bootstrap tuple. It bootstraps and
|
|
68
|
+
health-checks the replacement before sending the current daemon the private
|
|
69
|
+
retirement command. Retirement stops stable listeners and new work promptly,
|
|
70
|
+
but does not wait for owned workers' normal drain before the replacement binds
|
|
71
|
+
the proxy and control socket. A bootstrap failure occurs before retirement, so
|
|
72
|
+
the previously accepted owner remains available.
|
|
73
|
+
|
|
67
74
|
## `ensure-daemon`
|
|
68
75
|
|
|
69
76
|
```
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ export async function runCli(argv) {
|
|
|
36
36
|
.option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
|
|
37
37
|
.option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
|
|
38
38
|
.option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
|
|
39
|
+
.option("--takeover-owner", "Boot and health-check before retiring the current external owner")
|
|
39
40
|
.action(async (options) => {
|
|
40
41
|
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
41
42
|
const configPath = await resolveConfigPath(options.config)
|
|
@@ -43,7 +44,9 @@ export async function runCli(argv) {
|
|
|
43
44
|
const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
|
|
44
45
|
const daemon = new RollbridgeDaemon({bootstrap, config, configPath, runtime})
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
|
|
48
|
+
|
|
49
|
+
if (!options.takeoverOwner) await daemon.start({exposeControl: !bootstrap})
|
|
47
50
|
|
|
48
51
|
const shutdown = async () => {
|
|
49
52
|
await daemon.shutdown()
|
|
@@ -56,10 +59,15 @@ export async function runCli(argv) {
|
|
|
56
59
|
if (bootstrap) {
|
|
57
60
|
try {
|
|
58
61
|
await daemon.deploy(bootstrap)
|
|
59
|
-
|
|
62
|
+
if (options.takeoverOwner) {
|
|
63
|
+
await sendControlCommand({command: {attestation: bootstrap.attestation, command: "retire-owner"}, path: config.control.path})
|
|
64
|
+
await daemon.start({reportOrphans: false})
|
|
65
|
+
} else {
|
|
66
|
+
await daemon.exposeControl()
|
|
67
|
+
}
|
|
60
68
|
} catch {
|
|
61
69
|
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
|
|
62
|
-
await daemon.shutdown()
|
|
70
|
+
if (!options.takeoverOwner) await daemon.shutdown()
|
|
63
71
|
process.exitCode = 1
|
|
64
72
|
return
|
|
65
73
|
}
|
package/src/daemon.js
CHANGED
|
@@ -61,8 +61,10 @@ export default class RollbridgeDaemon {
|
|
|
61
61
|
this.stopping = false
|
|
62
62
|
this.statePath = config.statePath
|
|
63
63
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
64
|
+
this.persistenceEnabled = false
|
|
64
65
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
65
66
|
this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
67
|
+
this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
66
68
|
this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
67
69
|
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
68
70
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
@@ -74,11 +76,11 @@ export default class RollbridgeDaemon {
|
|
|
74
76
|
|
|
75
77
|
/**
|
|
76
78
|
* Starts daemon listeners.
|
|
77
|
-
* @param {{exposeControl?: boolean}} [options] -
|
|
79
|
+
* @param {{exposeControl?: boolean, reportOrphans?: boolean}} [options] - Listener startup options.
|
|
78
80
|
* @returns {Promise<void>} Resolves when the requested listeners are ready.
|
|
79
81
|
*/
|
|
80
|
-
async start({exposeControl = true} = {}) {
|
|
81
|
-
await this.reportOrphans()
|
|
82
|
+
async start({exposeControl = true, reportOrphans = true} = {}) {
|
|
83
|
+
if (reportOrphans) await this.reportOrphans()
|
|
82
84
|
await this.startProxy()
|
|
83
85
|
if (exposeControl) await this.exposeControl()
|
|
84
86
|
}
|
|
@@ -358,6 +360,13 @@ export default class RollbridgeDaemon {
|
|
|
358
360
|
return {message: "shutdown"}
|
|
359
361
|
}
|
|
360
362
|
|
|
363
|
+
if (commandName === "retire-owner") {
|
|
364
|
+
const attestation = requiredString(data.attestation, "attestation")
|
|
365
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(attestation)) throw new Error("Owner retirement attestation must use the canonical sha256:<64 lowercase hex> format")
|
|
366
|
+
await this.retireOwner({attestation, completionSocket: controlSocket})
|
|
367
|
+
return {message: "owner retired"}
|
|
368
|
+
}
|
|
369
|
+
|
|
361
370
|
throw new Error(`Unknown command: ${String(commandName)}`)
|
|
362
371
|
}
|
|
363
372
|
|
|
@@ -732,6 +741,7 @@ export default class RollbridgeDaemon {
|
|
|
732
741
|
startStatePersistence() {
|
|
733
742
|
if (!this.statePath) return
|
|
734
743
|
|
|
744
|
+
this.persistenceEnabled = true
|
|
735
745
|
this.persistState()
|
|
736
746
|
this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
|
|
737
747
|
this.persistTimer.unref?.()
|
|
@@ -743,7 +753,7 @@ export default class RollbridgeDaemon {
|
|
|
743
753
|
* @returns {void}
|
|
744
754
|
*/
|
|
745
755
|
persistState() {
|
|
746
|
-
if (!this.statePath || this.stopping) return
|
|
756
|
+
if (!this.statePath || !this.persistenceEnabled || this.stopping) return
|
|
747
757
|
|
|
748
758
|
const statePath = this.statePath
|
|
749
759
|
const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
|
|
@@ -797,6 +807,50 @@ export default class RollbridgeDaemon {
|
|
|
797
807
|
if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
|
|
798
808
|
}
|
|
799
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Relinquishes stable listeners promptly while retaining draining children under
|
|
812
|
+
* this daemon until their normal stop contract completes.
|
|
813
|
+
* @param {{attestation: string, completionSocket?: net.Socket}} options - Attested handoff request.
|
|
814
|
+
* @returns {Promise<void>} Resolves once a replacement can exclusively bind listeners.
|
|
815
|
+
*/
|
|
816
|
+
async retireOwner({attestation, completionSocket}) {
|
|
817
|
+
if (this.retirementPromise) return await this.retirementPromise
|
|
818
|
+
this.retirementPromise = this.performOwnerRetirement(attestation, completionSocket)
|
|
819
|
+
return await this.retirementPromise
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* @param {string} attestation - Replacement boot attestation.
|
|
824
|
+
* @param {net.Socket | undefined} completionSocket - Requesting handoff connection.
|
|
825
|
+
* @returns {Promise<void>} Resolves after quiesce and listener release.
|
|
826
|
+
*/
|
|
827
|
+
async performOwnerRetirement(attestation, completionSocket) {
|
|
828
|
+
this.stopping = true
|
|
829
|
+
if (this.persistTimer) {
|
|
830
|
+
clearInterval(this.persistTimer)
|
|
831
|
+
this.persistTimer = undefined
|
|
832
|
+
}
|
|
833
|
+
this.persistenceEnabled = false
|
|
834
|
+
if (this.pendingWrite) await this.pendingWrite
|
|
835
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
836
|
+
for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
|
|
837
|
+
await Promise.all([
|
|
838
|
+
...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
|
|
839
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
|
|
840
|
+
...[...this.startingReleases].map((release) => release.quiesce()),
|
|
841
|
+
...[...this.releases.values()].map((release) => release.quiesce())
|
|
842
|
+
])
|
|
843
|
+
await this.removeControlSocket()
|
|
844
|
+
void this.closeServer(this.proxyServer)
|
|
845
|
+
void Promise.allSettled([
|
|
846
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
847
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
848
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
849
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
850
|
+
])
|
|
851
|
+
this.logger("external owner retired", {attestation, status: "draining"})
|
|
852
|
+
}
|
|
853
|
+
|
|
800
854
|
/**
|
|
801
855
|
* @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
|
|
802
856
|
* @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
|
|
@@ -925,7 +979,7 @@ function isShutdownControlLine(line) {
|
|
|
925
979
|
try {
|
|
926
980
|
const command = JSON.parse(line)
|
|
927
981
|
|
|
928
|
-
return Boolean(command && typeof command === "object" &&
|
|
982
|
+
return Boolean(command && typeof command === "object" && ["retire-owner", "shutdown"].includes(command.command))
|
|
929
983
|
} catch {
|
|
930
984
|
return false
|
|
931
985
|
}
|
package/src/managed-process.js
CHANGED
|
@@ -62,6 +62,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
62
62
|
this.memoryWarned = false
|
|
63
63
|
this.startedAtMs = /** @type {number | undefined} */ (undefined)
|
|
64
64
|
this.intentionalStop = false
|
|
65
|
+
this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
65
66
|
this.restartTimer = undefined
|
|
66
67
|
this.child = undefined
|
|
67
68
|
this.exitPromise = undefined
|
|
@@ -78,6 +79,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
78
79
|
if (this.child) return
|
|
79
80
|
|
|
80
81
|
this.intentionalStop = false
|
|
82
|
+
this.quiescePromise = undefined
|
|
81
83
|
this.exitCode = undefined
|
|
82
84
|
this.exitSignal = undefined
|
|
83
85
|
this.state = "starting"
|
|
@@ -338,36 +340,22 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
338
340
|
* @returns {Promise<void>} Resolves when stopped.
|
|
339
341
|
*/
|
|
340
342
|
async stop(options = {}) {
|
|
341
|
-
this.
|
|
342
|
-
this.
|
|
343
|
-
|
|
344
|
-
if (this.restartTimer) {
|
|
345
|
-
clearTimeout(this.restartTimer)
|
|
346
|
-
this.restartTimer = undefined
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const child = this.child
|
|
343
|
+
const pgid = this.child?.pid ?? this.pid
|
|
344
|
+
const exitPromise = this.exitPromise
|
|
345
|
+
await this.quiesce()
|
|
350
346
|
|
|
351
|
-
if (!
|
|
347
|
+
if (!pgid) {
|
|
352
348
|
this.state = "stopped"
|
|
353
349
|
return
|
|
354
350
|
}
|
|
355
351
|
|
|
356
|
-
const
|
|
357
|
-
const exitPromise = this.exitPromise
|
|
358
|
-
|
|
359
|
-
this.state = "stopping"
|
|
360
|
-
|
|
361
|
-
const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
|
|
352
|
+
const {drainCommand, drainTimeoutMs, stopCommand} = this.lifecycle
|
|
362
353
|
|
|
363
354
|
const hookTimeoutMs = this.hookTimeoutMs()
|
|
364
355
|
|
|
365
|
-
// 1. Quiesce: tell the process to stop accepting new work.
|
|
366
|
-
if (quietCommand) await this.runHook(quietCommand, hookTimeoutMs, "quiet command")
|
|
367
|
-
|
|
368
356
|
// 2. Drain: let in-flight work finish, bounded by drainTimeoutMs (0 skips the step). A
|
|
369
357
|
// drainCommand blocks until drained; otherwise wait for the process to exit on its own.
|
|
370
|
-
if (this.
|
|
358
|
+
if (this.processGroupExists(pgid) && drainTimeoutMs > 0) {
|
|
371
359
|
if (drainCommand) await this.runHook(drainCommand, drainTimeoutMs, "drain command")
|
|
372
360
|
else await this.waitForExit(drainTimeoutMs)
|
|
373
361
|
}
|
|
@@ -391,6 +379,26 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
391
379
|
this.state = "stopped"
|
|
392
380
|
}
|
|
393
381
|
|
|
382
|
+
/** @returns {Promise<void>} Stops restarts and waits until the process has stopped accepting new work. */
|
|
383
|
+
async quiesce() {
|
|
384
|
+
if (this.quiescePromise) return await this.quiescePromise
|
|
385
|
+
this.quiescePromise = (async () => {
|
|
386
|
+
this.intentionalStop = true
|
|
387
|
+
this.clearMemoryMonitor()
|
|
388
|
+
if (this.restartTimer) {
|
|
389
|
+
clearTimeout(this.restartTimer)
|
|
390
|
+
this.restartTimer = undefined
|
|
391
|
+
}
|
|
392
|
+
if (!this.child?.pid) {
|
|
393
|
+
this.state = "stopped"
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
this.state = "stopping"
|
|
397
|
+
if (this.lifecycle.quietCommand) await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
|
|
398
|
+
})()
|
|
399
|
+
return await this.quiescePromise
|
|
400
|
+
}
|
|
401
|
+
|
|
394
402
|
/** @returns {number} Timeout used for lifecycle hooks. */
|
|
395
403
|
hookTimeoutMs() {
|
|
396
404
|
if (this.stopTimeoutMs === "indefinite") return 30000
|
package/src/release-group.js
CHANGED
|
@@ -403,6 +403,11 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
403
403
|
this.stoppedAt = new Date().toISOString()
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/** @returns {Promise<void>} Quiesces every release process without waiting for its drain. */
|
|
407
|
+
async quiesce() {
|
|
408
|
+
await Promise.all([...this.processes.values()].map((processInstance) => processInstance.quiesce()))
|
|
409
|
+
}
|
|
410
|
+
|
|
406
411
|
/** @returns {ReleaseStatus} Status payload. */
|
|
407
412
|
status() {
|
|
408
413
|
return {
|
|
@@ -85,6 +85,38 @@ test("daemon bootstrap activates the exact release through the foreground daemon
|
|
|
85
85
|
}
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
+
test("failed takeover bootstrap preserves the previously accepted owner", async () => {
|
|
89
|
+
const fixture = await createFixture()
|
|
90
|
+
const accepted = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "accepted", revision: "accepted123"})
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await waitForLog(accepted, "control socket listening")
|
|
94
|
+
const badConfig = JSON.parse((await fs.readFile(fixture.configPath, "utf8")).replace(/^module\.exports = /, ""))
|
|
95
|
+
badConfig.processes[0].health.path = "/never-ready"
|
|
96
|
+
badConfig.processes[0].health.timeoutMs = 100
|
|
97
|
+
await fs.writeFile(fixture.configPath, `module.exports = ${JSON.stringify(badConfig, null, 2)}\n`)
|
|
98
|
+
|
|
99
|
+
const result = await runDaemon([
|
|
100
|
+
"--config", fixture.configPath,
|
|
101
|
+
"--release-path", fixture.root,
|
|
102
|
+
"--release-id", "candidate",
|
|
103
|
+
"--revision", "candidate123",
|
|
104
|
+
"--boot-attestation", secondAttestation,
|
|
105
|
+
"--takeover-owner"
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
assert.notEqual(result.code, 0)
|
|
109
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
110
|
+
assert.equal(status.activeReleaseId, "accepted")
|
|
111
|
+
assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
|
|
112
|
+
assert.equal(status.bootstrap.attestation, firstAttestation)
|
|
113
|
+
} finally {
|
|
114
|
+
accepted.kill("SIGTERM")
|
|
115
|
+
if (accepted.exitCode === null) await once(accepted, "exit")
|
|
116
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
88
120
|
test("daemon bootstrap does not expose control deploys until activation completes", async () => {
|
|
89
121
|
const fixture = await createFixture({healthGate: true, healthTimeoutMs: 60000})
|
|
90
122
|
const started = waitForFile(fixture.startedPath)
|
package/test/rollbridge.test.js
CHANGED
|
@@ -855,6 +855,14 @@ test("a process over its memory limit is restarted with reason memory", {skip: p
|
|
|
855
855
|
assert.equal(hog.lastStartReason, "memory")
|
|
856
856
|
assert.equal(typeof hog.lastMemoryRestartAt, "string")
|
|
857
857
|
|
|
858
|
+
// Keep the replacement alive long enough to observe its next monitor sample. The fixture
|
|
859
|
+
// remains over the configured limit after every launch, otherwise it can restart again and
|
|
860
|
+
// clear rssBytes/children before this polling loop observes them on slower CI runners.
|
|
861
|
+
const hogProcess = daemon.activeRelease?.processes.get("hog")
|
|
862
|
+
|
|
863
|
+
assert.ok(hogProcess?.memory)
|
|
864
|
+
hogProcess.memory.limitBytes = Number.MAX_SAFE_INTEGER
|
|
865
|
+
|
|
858
866
|
// rssBytes is sampled on the monitor's interval; wait for a measurement of the running process.
|
|
859
867
|
await waitFor(() => {
|
|
860
868
|
const rssBytes = activeProcessStatus(daemon, "hog")?.rssBytes
|
|
@@ -115,6 +115,43 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
|
|
|
115
115
|
}
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
test("external-owner retirement releases listeners before a long-draining companion exits", async () => {
|
|
119
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-"))
|
|
120
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
121
|
+
const gatePath = path.join(root, "retire.fifo")
|
|
122
|
+
const stoppingPath = path.join(root, "stopping")
|
|
123
|
+
const gate = spawn("mkfifo", [gatePath])
|
|
124
|
+
assert.equal((await once(gate, "exit"))[0], 0)
|
|
125
|
+
const config = buildConfig(socketPath, {companion: {
|
|
126
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
127
|
+
id: "worker",
|
|
128
|
+
lifecycle: {drainCommand: `read released < ${JSON.stringify(gatePath)}`, drainTimeoutMs: 60000, quietCommand: `printf stopping > ${JSON.stringify(stoppingPath)}`},
|
|
129
|
+
policy: "companion"
|
|
130
|
+
}})
|
|
131
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
132
|
+
let replacement
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await daemon.start()
|
|
136
|
+
await daemon.deploy({releaseId: "old", releasePath: root, revision: "old"})
|
|
137
|
+
const retirement = sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: socketPath})
|
|
138
|
+
const response = await retirement
|
|
139
|
+
assert.deepEqual(response, {message: "owner retired", status: "success"})
|
|
140
|
+
assert.equal(await fs.readFile(stoppingPath, "utf8"), "stopping", "old worker must stop accepting work before listener takeover")
|
|
141
|
+
|
|
142
|
+
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
143
|
+
await replacement.start({reportOrphans: false})
|
|
144
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
|
|
145
|
+
assert.deepEqual(replacement.status().orphans, [], "intentional retired companions are not replacement orphans")
|
|
146
|
+
assert.equal(daemon.status().releases[0].processes[0].state, "stopping")
|
|
147
|
+
} finally {
|
|
148
|
+
await fs.writeFile(gatePath, "done\n").catch(() => {})
|
|
149
|
+
if (replacement) await replacement.shutdown()
|
|
150
|
+
await daemon.shutdown()
|
|
151
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
118
155
|
test("control socket unlink failure is reported only after owned cleanup completes", async () => {
|
|
119
156
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
|
|
120
157
|
const socketPath = path.join(root, "control.sock")
|