rollbridge 0.1.30 → 0.1.32
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/AGENTS.md +11 -6
- package/README.md +28 -12
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +9 -0
- package/docs/cli.md +10 -7
- package/docs/config.md +41 -13
- package/docs/tensorbuzz-runbook.md +4 -2
- package/docs/velocious.md +19 -7
- package/docs/workers.md +18 -9
- package/examples/tensorbuzz.com.js +9 -5
- package/package.json +1 -1
- package/src/config.js +32 -2
- package/src/daemon.js +305 -42
- package/src/guardian-client.js +46 -3
- package/src/managed-process.js +85 -13
- package/src/process-guardian.js +45 -1
- package/src/release-group.js +53 -14
- package/test/config-validation.test.js +44 -0
- package/test/guardian-client.test.js +70 -0
- package/test/managed-process.test.js +33 -0
- package/test/owner-recovery.test.js +396 -6
- package/test/owner-replacement.test.js +153 -3
- package/test/rollbridge.test.js +333 -6
|
@@ -9,8 +9,9 @@ import os from "node:os"
|
|
|
9
9
|
import path from "node:path"
|
|
10
10
|
import test from "node:test"
|
|
11
11
|
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {normalizeConfig} from "../src/config.js"
|
|
12
13
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
-
import {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
|
|
14
|
+
import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
|
|
14
15
|
import GuardianClient from "../src/guardian-client.js"
|
|
15
16
|
import {findAvailablePort} from "../src/port-allocator.js"
|
|
16
17
|
|
|
@@ -248,6 +249,50 @@ test("ensure-daemon atomically replaces incompatible config, socket, and package
|
|
|
248
249
|
}
|
|
249
250
|
})
|
|
250
251
|
|
|
252
|
+
test("same-authority replacement commits after a retired incumbent already removed its control socket", async () => {
|
|
253
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-retired-control-"))
|
|
254
|
+
const socketPath = path.join(root, "rollbridge.sock")
|
|
255
|
+
const statePath = path.join(root, "state.json")
|
|
256
|
+
const releasePath = path.join(root, "v1")
|
|
257
|
+
const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
258
|
+
const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
259
|
+
let replacement
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await fs.mkdir(releasePath)
|
|
263
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
264
|
+
await owner.start()
|
|
265
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
266
|
+
await Promise.all([...owner.releases.values()].map((release) => release.quiesce()))
|
|
267
|
+
await owner.closeServer(owner.controlServer)
|
|
268
|
+
await owner.removeControlSocket()
|
|
269
|
+
await owner.closeServer(owner.proxyServer)
|
|
270
|
+
const retired = owner.status()
|
|
271
|
+
const processState = retired.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
|
|
274
|
+
assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
|
|
275
|
+
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
276
|
+
|
|
277
|
+
replacement = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
278
|
+
await replacement.replaceIncompatibleOwner()
|
|
279
|
+
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
280
|
+
const recoveredReleases = /** @type {{processes: {id: string, pid?: number, state: string}[]}[]} */ (recovered.releases)
|
|
281
|
+
|
|
282
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
283
|
+
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
284
|
+
assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
|
|
285
|
+
} finally {
|
|
286
|
+
const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
|
|
287
|
+
|
|
288
|
+
await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
|
|
289
|
+
owner.guardian?.disconnect()
|
|
290
|
+
replacement?.guardian?.disconnect()
|
|
291
|
+
await stopGuardian(statePath)
|
|
292
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
251
296
|
test("replacement refuses to overwrite an unrelated live final control socket and preserves the owner", async () => {
|
|
252
297
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-fence-"))
|
|
253
298
|
const oldSocketPath = path.join(root, "old.sock")
|
|
@@ -377,6 +422,100 @@ test("replacement transfers an unchanged fixed proxy listener without reusePort"
|
|
|
377
422
|
}
|
|
378
423
|
})
|
|
379
424
|
|
|
425
|
+
test("owner replacement preserves committed generation metadata without firing lifecycle hooks", async () => {
|
|
426
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-generation-"))
|
|
427
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
428
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
429
|
+
const statePath = path.join(root, "state.json")
|
|
430
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
431
|
+
const releasePath = path.join(root, "v1")
|
|
432
|
+
const lifecycleLogPath = path.join(root, "generation.lifecycle")
|
|
433
|
+
let owner
|
|
434
|
+
let candidate
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
await fs.mkdir(releasePath)
|
|
438
|
+
await makeFifo(path.join(releasePath, "worker.fifo"))
|
|
439
|
+
await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: oldSocketPath, extraCompanion: false, statePath}))
|
|
440
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
441
|
+
await waitForLog(owner, "control socket listening")
|
|
442
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: oldSocketPath})
|
|
443
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\n")
|
|
444
|
+
|
|
445
|
+
await writeConfig(configPath, config({activationLogPath: lifecycleLogPath, controlPath: newSocketPath, extraCompanion: true, statePath}))
|
|
446
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
447
|
+
await waitForLog(candidate, "owner replacement committed")
|
|
448
|
+
|
|
449
|
+
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
450
|
+
const generationTransition = status.generationTransition
|
|
451
|
+
|
|
452
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
453
|
+
assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
|
|
454
|
+
assert.equal(generationTransition.phase, "committed")
|
|
455
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\n", "owner replacement must not reactivate an already committed generation")
|
|
456
|
+
|
|
457
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
458
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
459
|
+
await shutdown
|
|
460
|
+
} finally {
|
|
461
|
+
for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
462
|
+
await stopGuardian(statePath)
|
|
463
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
|
|
468
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
|
|
469
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
470
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
471
|
+
const statePath = path.join(root, "state.json")
|
|
472
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
473
|
+
const lifecycleLogPath = path.join(root, "generation.lifecycle")
|
|
474
|
+
const v1Path = path.join(root, "v1")
|
|
475
|
+
const v2Path = path.join(root, "v2")
|
|
476
|
+
let owner
|
|
477
|
+
let candidate
|
|
478
|
+
const failedConfig = (/** @type {string} */ controlPath, /** @type {boolean} */ extraCompanion) => {
|
|
479
|
+
const raw = config({activationLogPath: lifecycleLogPath, controlPath, extraCompanion, statePath})
|
|
480
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
|
|
481
|
+
const generationMain = processes.find((processConfig) => processConfig.id === "generation-main")
|
|
482
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generationMain?.lifecycle)
|
|
483
|
+
|
|
484
|
+
lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
485
|
+
return raw
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
|
|
490
|
+
await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
|
|
491
|
+
await writeConfig(configPath, failedConfig(oldSocketPath, false))
|
|
492
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
493
|
+
await waitForLog(owner, "control socket listening")
|
|
494
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
495
|
+
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
|
|
496
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
|
|
497
|
+
|
|
498
|
+
await writeConfig(configPath, failedConfig(newSocketPath, true))
|
|
499
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
500
|
+
await waitForLog(candidate, "owner replacement committed")
|
|
501
|
+
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
502
|
+
const generationTransition = status.generationTransition
|
|
503
|
+
|
|
504
|
+
assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
|
|
505
|
+
assert.equal(generationTransition.phase, "activating_candidate")
|
|
506
|
+
assert.match(String(generationTransition.error), /activate command exited non-zero/)
|
|
507
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
|
|
508
|
+
|
|
509
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
510
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
511
|
+
await shutdown
|
|
512
|
+
} finally {
|
|
513
|
+
for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
514
|
+
await stopGuardian(statePath)
|
|
515
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
|
|
380
519
|
test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
|
|
381
520
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
|
|
382
521
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
@@ -473,10 +612,10 @@ test("prepared replacement fences incumbent mutations until abort", async () =>
|
|
|
473
612
|
})
|
|
474
613
|
|
|
475
614
|
/**
|
|
476
|
-
* @param {{controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
|
|
615
|
+
* @param {{activationLogPath?: string, controlPath: string, extraCompanion: boolean, proxyPort?: number, statePath: string}} options - Fixture options.
|
|
477
616
|
* @returns {Record<string, import("../src/json.js").JsonValue>} Raw fixture config.
|
|
478
617
|
*/
|
|
479
|
-
function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
|
|
618
|
+
function config({activationLogPath, controlPath, extraCompanion, proxyPort = 0, statePath}) {
|
|
480
619
|
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
|
|
481
620
|
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([
|
|
482
621
|
{
|
|
@@ -489,6 +628,17 @@ function config({controlPath, extraCompanion, proxyPort = 0, statePath}) {
|
|
|
489
628
|
{command, health: {intervalMs: 25, path: "/ping", timeoutMs: 3000}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
|
|
490
629
|
])
|
|
491
630
|
|
|
631
|
+
if (activationLogPath) processes.unshift({
|
|
632
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
633
|
+
deployStrategy: "handoff",
|
|
634
|
+
id: "generation-main",
|
|
635
|
+
lifecycle: {
|
|
636
|
+
activateCommand: `printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(activationLogPath)}`,
|
|
637
|
+
quietCommand: `printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(activationLogPath)}`
|
|
638
|
+
},
|
|
639
|
+
policy: "service",
|
|
640
|
+
port: {from: 23000, to: 23999}
|
|
641
|
+
})
|
|
492
642
|
if (extraCompanion) processes.splice(1, 0, {command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`, id: "metrics", policy: "companion"})
|
|
493
643
|
return {
|
|
494
644
|
application: "owner-replacement-test",
|
package/test/rollbridge.test.js
CHANGED
|
@@ -505,6 +505,277 @@ test("candidate activation retires jobs-main with its workers without waiting fo
|
|
|
505
505
|
}
|
|
506
506
|
})
|
|
507
507
|
|
|
508
|
+
test("opt-in generation lifecycle retires the old generation before activating and committing the candidate", async () => {
|
|
509
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
510
|
+
const daemon = await startDaemon(fixture.config)
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
514
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
515
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
516
|
+
|
|
517
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
518
|
+
|
|
519
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
520
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
521
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed")
|
|
522
|
+
} finally {
|
|
523
|
+
await daemon.shutdown()
|
|
524
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test("generation commit is durable before awaited post-transition work", async () => {
|
|
529
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
|
|
530
|
+
const daemon = await startDaemon(fixture.config)
|
|
531
|
+
const replaceSingletons = daemon.replaceSingletons.bind(daemon)
|
|
532
|
+
/** @type {() => void} */
|
|
533
|
+
let markReplacementStarted = () => {}
|
|
534
|
+
/** @type {() => void} */
|
|
535
|
+
let releaseReplacement = () => {}
|
|
536
|
+
const replacementStarted = new Promise((resolve) => { markReplacementStarted = () => resolve(undefined) })
|
|
537
|
+
const replacementGate = new Promise((resolve) => { releaseReplacement = () => resolve(undefined) })
|
|
538
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
539
|
+
let deployPromise
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
543
|
+
daemon.replaceSingletons = async (release) => {
|
|
544
|
+
if (release.releaseId === "v2") {
|
|
545
|
+
markReplacementStarted()
|
|
546
|
+
await replacementGate
|
|
547
|
+
}
|
|
548
|
+
await replaceSingletons(release)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
deployPromise = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
552
|
+
await replacementStarted
|
|
553
|
+
|
|
554
|
+
const persisted = /** @type {{activeReleaseId?: string, generationTransition?: {phase?: string}, singletonReleaseIds?: Record<string, string>} | undefined} */ (await readState(fixture.statePath))
|
|
555
|
+
|
|
556
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
557
|
+
assert.equal(persisted?.activeReleaseId, "v2")
|
|
558
|
+
assert.equal(persisted?.generationTransition?.phase, "committed_pending")
|
|
559
|
+
assert.equal(persisted?.singletonReleaseIds?.["jobs-main"], "v1")
|
|
560
|
+
|
|
561
|
+
releaseReplacement()
|
|
562
|
+
await deployPromise
|
|
563
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed")
|
|
564
|
+
} finally {
|
|
565
|
+
releaseReplacement()
|
|
566
|
+
await deployPromise?.catch(() => {})
|
|
567
|
+
await daemon.shutdown()
|
|
568
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
569
|
+
}
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
test("exact committed retry finishes pending singleton replacement before success", async () => {
|
|
573
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, singletonCwd: "{{releasePath}}/{{releaseId}}", webDependsOnService: true})
|
|
574
|
+
const daemon = await startDaemon(fixture.config)
|
|
575
|
+
|
|
576
|
+
await fs.mkdir(path.join(fixture.root, "v1"))
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
580
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /ENOENT/)
|
|
581
|
+
|
|
582
|
+
assert.equal(daemon.status().activeReleaseId, "v2", "traffic remains durably committed")
|
|
583
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed_pending")
|
|
584
|
+
assert.notEqual(daemon.status().singletons[0]?.process.state, "running")
|
|
585
|
+
|
|
586
|
+
await fs.mkdir(path.join(fixture.root, "v2"))
|
|
587
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
588
|
+
|
|
589
|
+
assert.equal(daemon.status().generationTransition?.phase, "committed")
|
|
590
|
+
assert.equal(daemon.status().singletons[0]?.process.state, "running")
|
|
591
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
592
|
+
} finally {
|
|
593
|
+
await daemon.shutdown()
|
|
594
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
test("first generation is not committed when its activation acknowledgement fails", async () => {
|
|
599
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
600
|
+
const daemon = await startDaemon(fixture.config)
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"}), /activate command exited non-zero/)
|
|
604
|
+
const status = daemon.status()
|
|
605
|
+
|
|
606
|
+
assert.equal(status.activeReleaseId, null)
|
|
607
|
+
assert.equal(status.generationTransition?.phase, "activating_candidate")
|
|
608
|
+
assert.deepEqual(status.releaseReferences.map((reference) => reference.releaseId), ["v1"])
|
|
609
|
+
} finally {
|
|
610
|
+
await daemon.shutdown()
|
|
611
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test("retirement failure retains the exact transition, blocks other deploys, and exact resume continues it", async () => {
|
|
616
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceQuietFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
617
|
+
const daemon = await startDaemon(fixture.config)
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
621
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /retirement quiescence failed/)
|
|
622
|
+
|
|
623
|
+
const failed = daemon.status()
|
|
624
|
+
|
|
625
|
+
assert.equal(failed.activeReleaseId, "v1")
|
|
626
|
+
assert.equal(failed.generationTransition?.phase, "retiring_previous")
|
|
627
|
+
assert.match(String(failed.generationTransition?.error), /quiet command exited non-zero/)
|
|
628
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
629
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
|
|
630
|
+
|
|
631
|
+
await fs.writeFile(fixture.retirementGatePath, "allow\n")
|
|
632
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
633
|
+
|
|
634
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
635
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
636
|
+
} finally {
|
|
637
|
+
await daemon.shutdown()
|
|
638
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
|
|
643
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
644
|
+
const daemon = await startDaemon(fixture.config)
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
648
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
|
|
649
|
+
|
|
650
|
+
const failed = daemon.status()
|
|
651
|
+
|
|
652
|
+
assert.equal(failed.activeReleaseId, "v1")
|
|
653
|
+
assert.equal(failed.generationTransition?.phase, "activating_candidate")
|
|
654
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
|
|
655
|
+
|
|
656
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
657
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
658
|
+
|
|
659
|
+
assert.equal(daemon.status().activeReleaseId, "v2")
|
|
660
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
661
|
+
} finally {
|
|
662
|
+
await daemon.shutdown()
|
|
663
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
664
|
+
}
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
test("unresolved generation transition fences stop, restart, and rollback mutations", async () => {
|
|
668
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
669
|
+
const daemon = await startDaemon(fixture.config)
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
673
|
+
await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
|
|
674
|
+
|
|
675
|
+
await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
|
|
676
|
+
await assert.rejects(() => daemon.restartProcesses({processId: "beacon"}), /cannot restart.*generation transition.*unresolved/i)
|
|
677
|
+
await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
|
|
678
|
+
|
|
679
|
+
assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
|
|
680
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
681
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
682
|
+
assert.equal(await fetchText(daemon, "/release"), "v2")
|
|
683
|
+
} finally {
|
|
684
|
+
await daemon.shutdown()
|
|
685
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
test("active generation restores its exact lifecycle role after coordinator auto-restart", async () => {
|
|
690
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
|
|
691
|
+
const daemon = await startDaemon(fixture.config)
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
695
|
+
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
696
|
+
|
|
697
|
+
assert.ok(coordinator?.pid)
|
|
698
|
+
process.kill(-coordinator.pid, "SIGKILL")
|
|
699
|
+
await waitFor(async () => (await lifecycleEvents(fixture.lifecycleLogPath)).length === 2 && statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state === "running", 3000)
|
|
700
|
+
|
|
701
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "activate:v1"])
|
|
702
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")?.state, "running")
|
|
703
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
704
|
+
} finally {
|
|
705
|
+
await daemon.shutdown()
|
|
706
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
707
|
+
}
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
test("failed active-role restoration is loud and never reports the restarted coordinator running", async () => {
|
|
711
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: "v1", webDependsOnService: true})
|
|
712
|
+
const daemon = await startDaemon(fixture.config)
|
|
713
|
+
|
|
714
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
718
|
+
await fs.rm(fixture.activationGatePath)
|
|
719
|
+
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
720
|
+
|
|
721
|
+
assert.ok(coordinator?.pid)
|
|
722
|
+
process.kill(-coordinator.pid, "SIGKILL")
|
|
723
|
+
await waitFor(() => {
|
|
724
|
+
const status = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
725
|
+
|
|
726
|
+
return status?.state === "failed" && status.restarts === 1
|
|
727
|
+
}, 3000)
|
|
728
|
+
|
|
729
|
+
const failed = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
730
|
+
|
|
731
|
+
assert.equal(failed?.lifecycleRole, "active")
|
|
732
|
+
assert.equal(failed?.restarts, 1, "role restoration failure must not create an internal retry loop")
|
|
733
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
734
|
+
} finally {
|
|
735
|
+
await daemon.shutdown()
|
|
736
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
test("retired generation coordinator remains fenced after exit", async () => {
|
|
741
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
|
|
742
|
+
const daemon = await startDaemon(fixture.config)
|
|
743
|
+
/** @type {WebSocket | undefined} */
|
|
744
|
+
let socket
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
748
|
+
socket = await openWebSocket(daemon)
|
|
749
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
750
|
+
const coordinator = statusRelease(daemon, "v1").processes.find((entry) => entry.id === "beacon")
|
|
751
|
+
const coordinatorProcess = daemon.releases.get("v1")?.getProcess("beacon")
|
|
752
|
+
|
|
753
|
+
assert.ok(coordinator?.pid)
|
|
754
|
+
assert.ok(coordinatorProcess)
|
|
755
|
+
assert.equal(coordinator.lifecycleRole, "retired")
|
|
756
|
+
assert.equal(daemon.guardian?.processes.get("release:v1:beacon"), coordinatorProcess, "retirement refresh must preserve exact guardian event routing")
|
|
757
|
+
const exited = once(coordinatorProcess, "exit")
|
|
758
|
+
|
|
759
|
+
process.kill(-coordinator.pid, "SIGKILL")
|
|
760
|
+
const [exit] = await exited
|
|
761
|
+
const stopped = coordinatorProcess.status()
|
|
762
|
+
|
|
763
|
+
assert.equal(exit.code, null)
|
|
764
|
+
assert.equal(exit.id, "beacon")
|
|
765
|
+
assert.equal(exit.signal, "SIGKILL")
|
|
766
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
767
|
+
assert.equal(stopped.lifecycleRole, "retired")
|
|
768
|
+
assert.equal(stopped.pid, undefined)
|
|
769
|
+
assert.equal(stopped.restarts, 0)
|
|
770
|
+
assert.equal(stopped.state, "stopped")
|
|
771
|
+
assert.equal(coordinatorProcess.restartTimer, undefined, "retired process must not queue a restart")
|
|
772
|
+
} finally {
|
|
773
|
+
socket?.close()
|
|
774
|
+
await daemon.shutdown()
|
|
775
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
776
|
+
}
|
|
777
|
+
})
|
|
778
|
+
|
|
508
779
|
test("multiple retired jobs generations keep distinct endpoints and live references until completion", async () => {
|
|
509
780
|
const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, webDependsOnService: true})
|
|
510
781
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -819,7 +1090,11 @@ test("persisted daemon state excludes process commands, environment values, and
|
|
|
819
1090
|
|
|
820
1091
|
try {
|
|
821
1092
|
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
822
|
-
|
|
1093
|
+
const webProcess = daemon.activeRelease?.getProcess("web")
|
|
1094
|
+
|
|
1095
|
+
assert.ok(webProcess)
|
|
1096
|
+
await recordedLogLine(webProcess, secret)
|
|
1097
|
+
assert.ok(webProcess.status().logs.some((entry) => entry.line === secret), "secret output must be retained before persistence")
|
|
823
1098
|
|
|
824
1099
|
daemon.persistState()
|
|
825
1100
|
await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
|
|
@@ -1358,19 +1633,30 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1358
1633
|
})
|
|
1359
1634
|
|
|
1360
1635
|
/**
|
|
1361
|
-
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
1362
|
-
* @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1636
|
+
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
1637
|
+
* @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1363
1638
|
*/
|
|
1364
1639
|
async function createFixture(options = {}) {
|
|
1365
1640
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
|
|
1366
1641
|
const serviceLogPath = path.join(root, "service.log")
|
|
1367
1642
|
const serviceQuietPath = path.join(root, "service.quiet")
|
|
1643
|
+
const lifecycleLogPath = path.join(root, "service.lifecycle")
|
|
1644
|
+
const activationGatePath = path.join(root, "activation.allow")
|
|
1645
|
+
const retirementGatePath = path.join(root, "retirement.allow")
|
|
1368
1646
|
const singletonLogPath = path.join(root, "singleton.log")
|
|
1369
1647
|
const statePath = path.join(root, "rollbridge.state.json")
|
|
1370
1648
|
/** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
|
|
1371
1649
|
const processes = []
|
|
1372
1650
|
|
|
1373
1651
|
if (options.includeService || options.handoffService) {
|
|
1652
|
+
const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
|
|
1653
|
+
const lifecycle = options.handoffServiceActivate ? {
|
|
1654
|
+
activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
|
|
1655
|
+
quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
1656
|
+
} : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
|
|
1657
|
+
quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
|
|
1658
|
+
} : undefined
|
|
1659
|
+
|
|
1374
1660
|
processes.push({
|
|
1375
1661
|
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
|
|
1376
1662
|
...(options.handoffService ? {deployStrategy: "handoff"} : {}),
|
|
@@ -1378,7 +1664,7 @@ async function createFixture(options = {}) {
|
|
|
1378
1664
|
ROLLBRIDGE_SERVICE_LOG: serviceLogPath
|
|
1379
1665
|
},
|
|
1380
1666
|
id: "beacon",
|
|
1381
|
-
...(
|
|
1667
|
+
...(lifecycle ? {lifecycle} : {}),
|
|
1382
1668
|
policy: "service",
|
|
1383
1669
|
port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
|
|
1384
1670
|
restartDelayMs: 50
|
|
@@ -1463,10 +1749,18 @@ async function createFixture(options = {}) {
|
|
|
1463
1749
|
host: options.proxyHost || "127.0.0.1",
|
|
1464
1750
|
port: 0
|
|
1465
1751
|
},
|
|
1466
|
-
...(options.persistState ? {statePath} : {})
|
|
1752
|
+
...((options.persistState || options.handoffServiceActivate) ? {ownerRecovery: {reconnectGraceMs: 30000}, statePath} : {})
|
|
1467
1753
|
})
|
|
1468
1754
|
|
|
1469
|
-
return {config, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
|
|
1755
|
+
return {activationGatePath, config, lifecycleLogPath, retirementGatePath, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* @param {string} lifecycleLogPath - Fixture lifecycle event file.
|
|
1760
|
+
* @returns {Promise<string[]>} Ordered lifecycle events.
|
|
1761
|
+
*/
|
|
1762
|
+
async function lifecycleEvents(lifecycleLogPath) {
|
|
1763
|
+
return (await fs.readFile(lifecycleLogPath, "utf8")).trim().split("\n").filter(Boolean)
|
|
1470
1764
|
}
|
|
1471
1765
|
|
|
1472
1766
|
/**
|
|
@@ -1604,6 +1898,39 @@ async function waitFor(callback, timeoutMs = 3000) {
|
|
|
1604
1898
|
throw new Error("Timed out waiting for condition")
|
|
1605
1899
|
}
|
|
1606
1900
|
|
|
1901
|
+
/**
|
|
1902
|
+
* Resolves from retained output or the exact event that records it, and rejects if the
|
|
1903
|
+
* supervised process exits first.
|
|
1904
|
+
* @param {import("../src/managed-process.js").default} processInstance - Exact managed process.
|
|
1905
|
+
* @param {string} line - Complete output line to observe.
|
|
1906
|
+
* @returns {Promise<import("../src/managed-process.js").ManagedProcessLog>} Recorded log entry.
|
|
1907
|
+
*/
|
|
1908
|
+
async function recordedLogLine(processInstance, line) {
|
|
1909
|
+
const retained = processInstance.status().logs.find((entry) => entry.line === line)
|
|
1910
|
+
|
|
1911
|
+
if (retained) return retained
|
|
1912
|
+
return await new Promise((resolve, reject) => {
|
|
1913
|
+
/** @param {import("../src/managed-process.js").ManagedProcessLog} entry - Newly retained output. */
|
|
1914
|
+
const onLog = (entry) => {
|
|
1915
|
+
if (entry.line !== line) return
|
|
1916
|
+
cleanup()
|
|
1917
|
+
resolve(entry)
|
|
1918
|
+
}
|
|
1919
|
+
/** @param {{code: number | null, signal: import("node:child_process").ChildProcess["signalCode"]}} exit - Exact process exit. */
|
|
1920
|
+
const onExit = (exit) => {
|
|
1921
|
+
cleanup()
|
|
1922
|
+
reject(new Error(`Process ${processInstance.id} exited before recording expected output ${JSON.stringify(line)}: ${JSON.stringify(exit)}`))
|
|
1923
|
+
}
|
|
1924
|
+
const cleanup = () => {
|
|
1925
|
+
processInstance.off("log", onLog)
|
|
1926
|
+
processInstance.off("exit", onExit)
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
processInstance.on("log", onLog)
|
|
1930
|
+
processInstance.once("exit", onExit)
|
|
1931
|
+
})
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1607
1934
|
/**
|
|
1608
1935
|
* @param {RollbridgeDaemon} daemon - Daemon.
|
|
1609
1936
|
* @param {string} processId - Process id within the active release.
|