rollbridge 0.1.33 → 0.1.35
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.
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
### Fixed
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
- Select retired-owner commit proof from the guardian-published committed owner
|
|
4
|
+
snapshot rather than candidate-local process order, and independently require
|
|
5
|
+
that proof to remain registered while retaining the replacement transaction,
|
|
6
|
+
authority, and control-path fences.
|
|
7
|
+
- Fail closed when an older retained guardian cannot commit that replacement
|
|
8
|
+
atomically after the incumbent control socket disappears, preserving the
|
|
9
|
+
incumbent owner, retained connections, and guardian-managed release processes.
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -353,6 +353,7 @@ export default class RollbridgeDaemon {
|
|
|
353
353
|
let retiredIncumbentControl = false
|
|
354
354
|
let retainIncumbentControl = false
|
|
355
355
|
let incumbentControl = legacyBridge?.incumbentControl
|
|
356
|
+
let committed = /** @type {Promise<Error | undefined> | undefined} */ (undefined)
|
|
356
357
|
|
|
357
358
|
try {
|
|
358
359
|
if (this.config.control.path !== transfer.snapshot.control.path) {
|
|
@@ -401,7 +402,10 @@ export default class RollbridgeDaemon {
|
|
|
401
402
|
finalControlPublished = true
|
|
402
403
|
this.boundControlPath = this.config.control.path
|
|
403
404
|
}
|
|
404
|
-
|
|
405
|
+
committed = this.guardian.waitForEvent("replacement-committed").then(
|
|
406
|
+
() => undefined,
|
|
407
|
+
(error) => error instanceof Error ? error : new Error(String(error))
|
|
408
|
+
)
|
|
405
409
|
const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
|
|
406
410
|
authority: this.ownerAuthority(),
|
|
407
411
|
config: this.config,
|
|
@@ -412,10 +416,20 @@ export default class RollbridgeDaemon {
|
|
|
412
416
|
|
|
413
417
|
if (!staged.committed) {
|
|
414
418
|
if (retiredIncumbentControl) {
|
|
415
|
-
const
|
|
419
|
+
const recoveredProcesses = this.guardian.processes
|
|
420
|
+
const processKey = ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
|
|
421
|
+
.find((key) => recoveredProcesses.has(key))
|
|
416
422
|
|
|
417
|
-
if (!processKey) throw new Error("Retired owner replacement requires an exact recovered
|
|
418
|
-
|
|
423
|
+
if (!processKey) throw new Error("Retired owner replacement requires an exact recovered process from committed owner state")
|
|
424
|
+
try {
|
|
425
|
+
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (!(error instanceof Error) || error.message !== "Guardian commit-retired-owner-replacement requires the committed owner") throw error
|
|
428
|
+
throw new Error(
|
|
429
|
+
"Cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved",
|
|
430
|
+
{cause: error}
|
|
431
|
+
)
|
|
432
|
+
}
|
|
419
433
|
} else {
|
|
420
434
|
try {
|
|
421
435
|
if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
|
|
@@ -428,7 +442,9 @@ export default class RollbridgeDaemon {
|
|
|
428
442
|
}
|
|
429
443
|
}
|
|
430
444
|
}
|
|
431
|
-
await committed
|
|
445
|
+
const commitmentError = await committed
|
|
446
|
+
|
|
447
|
+
if (commitmentError) throw commitmentError
|
|
432
448
|
committedAuthority = true
|
|
433
449
|
if (retiredIncumbentControl) {
|
|
434
450
|
await fs.rename(stagingControlPath, this.config.control.path)
|
|
@@ -475,6 +491,13 @@ export default class RollbridgeDaemon {
|
|
|
475
491
|
} else {
|
|
476
492
|
this.guardian.disconnect()
|
|
477
493
|
}
|
|
494
|
+
if (committed) {
|
|
495
|
+
const commitmentError = await committed
|
|
496
|
+
|
|
497
|
+
if (commitmentError && commitmentError !== error && commitmentError.message !== "Process guardian connection closed") {
|
|
498
|
+
abortError = commitmentError
|
|
499
|
+
}
|
|
500
|
+
}
|
|
478
501
|
incumbentControl?.close()
|
|
479
502
|
if (legacyBridge?.boundaryCrossed) {
|
|
480
503
|
this.logger("legacy disruptive owner replacement failed after incumbent exit", {
|
|
@@ -523,7 +546,7 @@ export default class RollbridgeDaemon {
|
|
|
523
546
|
*/
|
|
524
547
|
async prepareLegacyOwnerReplacement({error, persisted, persistedAuthority}) {
|
|
525
548
|
const diagnostic = error instanceof Error ? error.message : String(error)
|
|
526
|
-
const legacyProcessKey =
|
|
549
|
+
const legacyProcessKey = ownerSnapshotProcessKeys(persisted, persisted.singletonReleaseIds)[0]
|
|
527
550
|
|
|
528
551
|
if (!isLegacyGuardianPrepareDiagnostic(diagnostic)) throw error
|
|
529
552
|
if (!legacyProcessKey) throw new Error("Legacy disruptive owner replacement requires an exact guardian-owned process registration in durable state", {cause: error})
|
|
@@ -1975,18 +1998,21 @@ export function isLegacyGuardianPrepareDiagnostic(diagnostic) {
|
|
|
1975
1998
|
}
|
|
1976
1999
|
|
|
1977
2000
|
/**
|
|
1978
|
-
* @param {OwnerRecoverySnapshot} snapshot - Durable
|
|
2001
|
+
* @param {OwnerRecoverySnapshot} snapshot - Durable committed owner snapshot.
|
|
2002
|
+
* @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
|
|
1979
2003
|
* @returns {string[]} Exact guardian registration keys present in the snapshot.
|
|
1980
2004
|
*/
|
|
1981
|
-
function
|
|
2005
|
+
function ownerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
|
|
1982
2006
|
const keys = []
|
|
1983
2007
|
|
|
1984
2008
|
for (const release of snapshot.releases) {
|
|
1985
2009
|
for (const processStatus of release.processes) keys.push(`release:${release.releaseId}:${processStatus.id}`)
|
|
1986
2010
|
}
|
|
1987
2011
|
for (const service of snapshot.services) keys.push(`service:${service.id}`)
|
|
1988
|
-
|
|
1989
|
-
|
|
2012
|
+
for (const singleton of snapshot.singletons) {
|
|
2013
|
+
const releaseId = singletonReleaseIds[singleton.id] || snapshot.activeReleaseId
|
|
2014
|
+
|
|
2015
|
+
if (releaseId) keys.push(`singleton:${releaseId}:${singleton.id}`)
|
|
1990
2016
|
}
|
|
1991
2017
|
return keys
|
|
1992
2018
|
}
|
package/src/process-guardian.js
CHANGED
|
@@ -68,7 +68,7 @@ let ownerState = bootstrap.ownerState
|
|
|
68
68
|
let ownerRevision = ownerState === undefined ? 0 : 1
|
|
69
69
|
/** @type {number | undefined} */
|
|
70
70
|
let replacementRevision
|
|
71
|
-
const legacyKeys = legacyGuardian ?
|
|
71
|
+
const legacyKeys = legacyGuardian ? committedOwnerProcessKeys(ownerState) : new Set()
|
|
72
72
|
/** @type {net.Socket | undefined} */
|
|
73
73
|
let ownerMutationClient
|
|
74
74
|
/** @type {string | undefined} */
|
|
@@ -301,6 +301,9 @@ async function execute(request, socket) {
|
|
|
301
301
|
if (request.command === "commit-retired-owner-replacement") {
|
|
302
302
|
requireReplacement(socket, request)
|
|
303
303
|
requireProcess(request)
|
|
304
|
+
if (!committedOwnerProcessKeys(ownerState).has(/** @type {string} */ (request.key))) {
|
|
305
|
+
throw new Error(`Guardian process ${request.key} does not belong to the committed owner`)
|
|
306
|
+
}
|
|
304
307
|
if (!replacementOwnerState) throw new Error("Retired owner replacement transaction is not staged")
|
|
305
308
|
if (!isDeepStrictEqual(ownerAuthority(ownerState), replacementAuthority)) throw new Error("Retired owner replacement requires unchanged owner authority")
|
|
306
309
|
const controlPath = ownerControlPath(ownerState)
|
|
@@ -622,11 +625,11 @@ function errorMessage(error) {
|
|
|
622
625
|
}
|
|
623
626
|
|
|
624
627
|
/**
|
|
625
|
-
* Derives only exact process keys
|
|
626
|
-
* @param {import("./json.js").JsonValue | undefined} state -
|
|
627
|
-
* @returns {Set<string>} Exact
|
|
628
|
+
* Derives only exact process keys serialized by committed private owner state.
|
|
629
|
+
* @param {import("./json.js").JsonValue | undefined} state - Committed owner state.
|
|
630
|
+
* @returns {Set<string>} Exact committed registrations eligible as owner proof.
|
|
628
631
|
*/
|
|
629
|
-
function
|
|
632
|
+
function committedOwnerProcessKeys(state) {
|
|
630
633
|
const keys = new Set()
|
|
631
634
|
|
|
632
635
|
if (!state || typeof state !== "object" || Array.isArray(state) || !("snapshot" in state)) return keys
|
|
@@ -646,9 +649,18 @@ function legacyOwnerKeys(state) {
|
|
|
646
649
|
if (service && typeof service === "object" && !Array.isArray(service) && typeof service.id === "string") keys.add(`service:${service.id}`)
|
|
647
650
|
}
|
|
648
651
|
}
|
|
649
|
-
|
|
652
|
+
const singletonReleaseIds = "singletonReleaseIds" in state && state.singletonReleaseIds && typeof state.singletonReleaseIds === "object" && !Array.isArray(state.singletonReleaseIds)
|
|
653
|
+
? state.singletonReleaseIds
|
|
654
|
+
: {}
|
|
655
|
+
|
|
656
|
+
if (Array.isArray(snapshot.singletons)) {
|
|
650
657
|
for (const singleton of snapshot.singletons) {
|
|
651
|
-
if (singleton
|
|
658
|
+
if (!singleton || typeof singleton !== "object" || Array.isArray(singleton) || typeof singleton.id !== "string") continue
|
|
659
|
+
const releaseId = singleton.id in singletonReleaseIds && typeof singletonReleaseIds[singleton.id] === "string"
|
|
660
|
+
? singletonReleaseIds[singleton.id]
|
|
661
|
+
: snapshot.activeReleaseId
|
|
662
|
+
|
|
663
|
+
if (typeof releaseId === "string") keys.add(`singleton:${releaseId}:${singleton.id}`)
|
|
652
664
|
}
|
|
653
665
|
}
|
|
654
666
|
return keys
|
|
@@ -224,6 +224,42 @@ test("retired owner replacement commit carries its exact recovered process key",
|
|
|
224
224
|
await client.commitRetiredOwnerReplacement(replacementId, processKey)
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
+
test("retired owner replacement rejects a registered process absent from committed owner state", async () => {
|
|
228
|
+
const fixture = await createGuardian()
|
|
229
|
+
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
230
|
+
const committedProcessKey = "release:v1:worker"
|
|
231
|
+
const candidateProcessKey = "release:candidate:worker"
|
|
232
|
+
const authority = {configDigest: "owner", runtime: null}
|
|
233
|
+
const snapshot = {
|
|
234
|
+
activeReleaseId: "v1",
|
|
235
|
+
control: {path: path.join(fixture.root, "rollbridge.sock")},
|
|
236
|
+
releases: [{processes: [{id: "worker"}], releaseId: "v1"}],
|
|
237
|
+
services: [],
|
|
238
|
+
singletons: []
|
|
239
|
+
}
|
|
240
|
+
const candidateSnapshot = {
|
|
241
|
+
...snapshot,
|
|
242
|
+
releases: [...snapshot.releases, {processes: [{id: "worker"}], releaseId: "candidate"}]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await fixture.client.process(committedProcessKey, definition("worker")).recover()
|
|
247
|
+
await fixture.client.process(candidateProcessKey, definition("candidate-worker")).recover()
|
|
248
|
+
await fixture.client.publishOwnerState({authority, snapshot})
|
|
249
|
+
await candidate.connect()
|
|
250
|
+
const prepared = await candidate.prepareOwnerReplacement(authority, authority)
|
|
251
|
+
|
|
252
|
+
await candidate.stageOwnerReplacement(prepared.replacementId, {authority, snapshot: candidateSnapshot})
|
|
253
|
+
await assert.rejects(
|
|
254
|
+
() => candidate.commitRetiredOwnerReplacement(prepared.replacementId, candidateProcessKey),
|
|
255
|
+
/process .* does not belong to the committed owner/
|
|
256
|
+
)
|
|
257
|
+
} finally {
|
|
258
|
+
candidate.disconnect()
|
|
259
|
+
await cleanupGuardian(fixture)
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
227
263
|
test("retired owner replacement requires unchanged authority and the exact control path absent", async () => {
|
|
228
264
|
const fixture = await createGuardian()
|
|
229
265
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
@@ -232,7 +268,13 @@ test("retired owner replacement requires unchanged authority and the exact contr
|
|
|
232
268
|
const processKey = "release:v1:worker"
|
|
233
269
|
const authority = {configDigest: "incumbent", runtime: null}
|
|
234
270
|
const nextAuthority = {configDigest: "candidate", runtime: null}
|
|
235
|
-
const snapshot = {
|
|
271
|
+
const snapshot = {
|
|
272
|
+
activeReleaseId: "v1",
|
|
273
|
+
control: {path: controlPath},
|
|
274
|
+
releases: [{processes: [{id: "worker"}], releaseId: "v1"}],
|
|
275
|
+
services: [],
|
|
276
|
+
singletons: []
|
|
277
|
+
}
|
|
236
278
|
|
|
237
279
|
try {
|
|
238
280
|
await fixture.client.process(processKey, definition("worker")).recover()
|
|
@@ -260,6 +302,10 @@ test("retired owner replacement requires unchanged authority and the exact contr
|
|
|
260
302
|
() => contender.request({command: "commit-retired-owner-replacement", key: processKey, replacementId: ready.replacementId}),
|
|
261
303
|
/not the prepared candidate/
|
|
262
304
|
)
|
|
305
|
+
await assert.rejects(
|
|
306
|
+
() => candidate.commitRetiredOwnerReplacement("stale-replacement", processKey),
|
|
307
|
+
/not the prepared candidate/
|
|
308
|
+
)
|
|
263
309
|
await assert.rejects(
|
|
264
310
|
() => candidate.commitRetiredOwnerReplacement(ready.replacementId, "release:v1:wrong"),
|
|
265
311
|
/process .* is not registered/
|
|
@@ -249,13 +249,161 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
|
|
|
249
249
|
}
|
|
250
250
|
})
|
|
251
251
|
|
|
252
|
-
test("
|
|
252
|
+
test("cross-version replacement fails closed without dropping a retained WebSocket after the public socket is removed", async () => {
|
|
253
253
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
|
|
254
254
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
255
255
|
const statePath = path.join(root, "state.json")
|
|
256
|
+
const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
|
|
257
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
258
|
+
const releasePath = path.join(root, "v1")
|
|
259
|
+
const compatibilitySockets = new Set()
|
|
260
|
+
let compatibilityGuardian
|
|
261
|
+
let committedProcessKey
|
|
262
|
+
let retainedGuardianSocketPath = /** @type {string | undefined} */ (undefined)
|
|
263
|
+
let owner = /** @type {import("node:child_process").ChildProcess | undefined} */ (undefined)
|
|
264
|
+
let replacement = /** @type {RollbridgeDaemon | undefined} */ (undefined)
|
|
265
|
+
let retainedConnection
|
|
266
|
+
let retainedConnectionClosed = false
|
|
267
|
+
let transactionAudit
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await fs.mkdir(releasePath)
|
|
271
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
272
|
+
await writeConfig(configPath, config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
273
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
274
|
+
await waitForLog(owner, "control socket listening")
|
|
275
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: socketPath})
|
|
276
|
+
const retired = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
277
|
+
const retiredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (retired.releases)
|
|
278
|
+
const processState = retiredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
279
|
+
const proxyPort = /** @type {{port?: number}} */ (retired.proxy).port
|
|
280
|
+
|
|
281
|
+
assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
|
|
282
|
+
if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
|
|
283
|
+
retainedConnection = await openWebSocket(proxyPort)
|
|
284
|
+
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
285
|
+
await fs.rm(socketPath)
|
|
286
|
+
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
287
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
288
|
+
const guardianSocketPath = state.recovery.guardian.socketPath
|
|
289
|
+
const expectedProcessKey = "release:v1:worker"
|
|
290
|
+
|
|
291
|
+
if (typeof guardianSocketPath !== "string") throw new Error("Retained guardian state is missing its socket path")
|
|
292
|
+
retainedGuardianSocketPath = guardianSocketPath
|
|
293
|
+
|
|
294
|
+
compatibilityGuardian = net.createServer((candidateSocket) => {
|
|
295
|
+
const guardianSocket = net.createConnection(guardianSocketPath)
|
|
296
|
+
let buffer = ""
|
|
297
|
+
|
|
298
|
+
compatibilitySockets.add(candidateSocket)
|
|
299
|
+
compatibilitySockets.add(guardianSocket)
|
|
300
|
+
candidateSocket.setEncoding("utf8")
|
|
301
|
+
candidateSocket.once("close", () => {
|
|
302
|
+
compatibilitySockets.delete(candidateSocket)
|
|
303
|
+
guardianSocket.destroy()
|
|
304
|
+
})
|
|
305
|
+
guardianSocket.once("close", () => {
|
|
306
|
+
compatibilitySockets.delete(guardianSocket)
|
|
307
|
+
candidateSocket.destroy()
|
|
308
|
+
})
|
|
309
|
+
guardianSocket.on("data", (chunk) => candidateSocket.write(chunk))
|
|
310
|
+
candidateSocket.on("data", (chunk) => {
|
|
311
|
+
buffer += chunk
|
|
312
|
+
let newline = buffer.indexOf("\n")
|
|
313
|
+
|
|
314
|
+
while (newline >= 0) {
|
|
315
|
+
const line = buffer.slice(0, newline)
|
|
316
|
+
const request = JSON.parse(line)
|
|
317
|
+
|
|
318
|
+
buffer = buffer.slice(newline + 1)
|
|
319
|
+
if (request.command === "commit-retired-owner-replacement") {
|
|
320
|
+
if (request.key !== expectedProcessKey) {
|
|
321
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires a process key`, id: request.id})}\n`)
|
|
322
|
+
} else {
|
|
323
|
+
committedProcessKey = request.key
|
|
324
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires the committed owner`, id: request.id})}\n`)
|
|
325
|
+
}
|
|
326
|
+
newline = buffer.indexOf("\n")
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
guardianSocket.write(`${line}\n`)
|
|
330
|
+
newline = buffer.indexOf("\n")
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
await listenUnix(compatibilityGuardian, compatibilitySocketPath)
|
|
335
|
+
state.recovery.guardian.socketPath = compatibilitySocketPath
|
|
336
|
+
await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
|
|
337
|
+
|
|
338
|
+
const incumbentPid = owner.pid
|
|
339
|
+
|
|
340
|
+
assert.ok(incumbentPid)
|
|
341
|
+
const candidate = new RollbridgeDaemon({
|
|
342
|
+
config: normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath})),
|
|
343
|
+
configPath,
|
|
344
|
+
legacyIncumbentPid: incumbentPid,
|
|
345
|
+
logger: () => {}
|
|
346
|
+
})
|
|
347
|
+
replacement = candidate
|
|
348
|
+
await assert.rejects(
|
|
349
|
+
() => candidate.replaceIncompatibleOwner(),
|
|
350
|
+
/cannot safely complete atomic owner replacement through the older retained guardian while the incumbent control socket is absent; incumbent owner and connections were preserved/i
|
|
351
|
+
)
|
|
352
|
+
assert.equal(committedProcessKey, expectedProcessKey)
|
|
353
|
+
assert.equal(owner.exitCode, null)
|
|
354
|
+
assert.equal(owner.signalCode, null)
|
|
355
|
+
assert.doesNotThrow(() => process.kill(incumbentPid, 0))
|
|
356
|
+
assert.equal(retainedConnectionClosed, false, "failed compatibility handoff must leave retained connections serving")
|
|
357
|
+
assert.equal(retainedConnection.destroyed, false, "failed compatibility handoff must preserve the incumbent listener")
|
|
358
|
+
for (const {pid} of processState || []) {
|
|
359
|
+
if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
|
|
360
|
+
assert.doesNotThrow(() => process.kill(pid, 0))
|
|
361
|
+
}
|
|
362
|
+
transactionAudit = new GuardianClient(state.recovery.guardian)
|
|
363
|
+
await transactionAudit.connect()
|
|
364
|
+
const transactionStatus = /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementPending?: boolean}} */ (await transactionAudit.replacementStatus())
|
|
365
|
+
|
|
366
|
+
assert.equal(transactionStatus.committedReplacementId, null)
|
|
367
|
+
assert.equal(transactionStatus.ownerClaimed, true)
|
|
368
|
+
assert.equal(transactionStatus.retirementPending, false)
|
|
369
|
+
} finally {
|
|
370
|
+
if (replacement?.controlCommandsReady) {
|
|
371
|
+
await Promise.all([
|
|
372
|
+
fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
|
|
373
|
+
replacement.shutdown().catch(() => {})
|
|
374
|
+
])
|
|
375
|
+
}
|
|
376
|
+
transactionAudit?.disconnect()
|
|
377
|
+
retainedConnection?.destroy()
|
|
378
|
+
if (owner && owner.exitCode === null && owner.signalCode === null) {
|
|
379
|
+
owner.kill("SIGKILL")
|
|
380
|
+
await once(owner, "exit")
|
|
381
|
+
}
|
|
382
|
+
replacement?.guardian?.disconnect()
|
|
383
|
+
for (const socket of compatibilitySockets) socket.destroy()
|
|
384
|
+
if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
|
|
385
|
+
if (retainedGuardianSocketPath) {
|
|
386
|
+
const cleanupState = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
387
|
+
|
|
388
|
+
cleanupState.recovery.guardian.socketPath = retainedGuardianSocketPath
|
|
389
|
+
await fs.writeFile(statePath, `${JSON.stringify(cleanupState)}\n`)
|
|
390
|
+
}
|
|
391
|
+
await stopGuardian(statePath)
|
|
392
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test("same-authority replacement commits with committed-owner proof after the incumbent control socket is removed", async () => {
|
|
397
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-committed-proof-"))
|
|
398
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
399
|
+
const statePath = path.join(root, "state.json")
|
|
256
400
|
const releasePath = path.join(root, "v1")
|
|
257
401
|
const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
258
402
|
const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
403
|
+
const candidateProcessKey = "release:candidate:worker"
|
|
404
|
+
const committedOwnerProcessKey = "release:v1:worker"
|
|
405
|
+
let committedProcessKey
|
|
406
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
259
407
|
let replacement
|
|
260
408
|
|
|
261
409
|
try {
|
|
@@ -263,6 +411,16 @@ test("same-authority replacement commits after a retired incumbent already remov
|
|
|
263
411
|
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
264
412
|
await owner.start()
|
|
265
413
|
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
414
|
+
const ownerProcess = owner.guardian?.processes.values().next().value
|
|
415
|
+
|
|
416
|
+
assert.ok(owner.guardian)
|
|
417
|
+
assert.ok(ownerProcess)
|
|
418
|
+
await owner.guardian.request({
|
|
419
|
+
command: "register",
|
|
420
|
+
definition: ownerProcess.definition,
|
|
421
|
+
key: candidateProcessKey,
|
|
422
|
+
provenance: ownerProcess.provenance
|
|
423
|
+
})
|
|
266
424
|
await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
|
|
267
425
|
await owner.closeServer(owner.controlServer)
|
|
268
426
|
await owner.removeControlSocket()
|
|
@@ -274,12 +432,29 @@ test("same-authority replacement commits after a retired incumbent already remov
|
|
|
274
432
|
assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
|
|
275
433
|
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
276
434
|
|
|
277
|
-
replacement = new RollbridgeDaemon({
|
|
435
|
+
replacement = new RollbridgeDaemon({
|
|
436
|
+
config: daemonConfig,
|
|
437
|
+
logger: (message) => {
|
|
438
|
+
if (message !== "owner replacement candidate prepared" || !replacement?.guardian) return
|
|
439
|
+
const guardian = replacement.guardian
|
|
440
|
+
const recoveredProcess = guardian.processes.values().next().value
|
|
441
|
+
|
|
442
|
+
if (!recoveredProcess) throw new Error("Replacement did not reconstruct a guardian process")
|
|
443
|
+
guardian.processes = new Map([[candidateProcessKey, recoveredProcess], ...guardian.processes])
|
|
444
|
+
const commitRetiredOwnerReplacement = guardian.commitRetiredOwnerReplacement.bind(guardian)
|
|
445
|
+
|
|
446
|
+
guardian.commitRetiredOwnerReplacement = async (replacementId, processKey) => {
|
|
447
|
+
committedProcessKey = processKey
|
|
448
|
+
await commitRetiredOwnerReplacement(replacementId, processKey)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
})
|
|
278
452
|
await replacement.replaceIncompatibleOwner()
|
|
279
453
|
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
280
454
|
const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
|
|
281
455
|
|
|
282
456
|
assert.equal(recovered.activeReleaseId, "v1")
|
|
457
|
+
assert.equal(committedProcessKey, committedOwnerProcessKey)
|
|
283
458
|
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
284
459
|
assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
|
|
285
460
|
} finally {
|