rollbridge 0.1.40 → 0.1.42
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 +3 -1
- package/docs/config.md +4 -3
- package/examples/tensorbuzz.com.js +5 -2
- package/package.json +1 -1
- package/src/cli.js +25 -0
- package/src/config.js +32 -5
- package/src/daemon.js +180 -3
- package/src/guardian-client.js +55 -2
- package/src/managed-process.js +71 -11
- package/src/process-guardian.js +4 -1
- package/src/release-group.js +22 -0
- package/test/completion.test.js +4 -2
- package/test/config-examples.test.js +1 -0
- package/test/config-validation.test.js +23 -3
- package/test/guardian-client.test.js +82 -1
- package/test/managed-process.test.js +29 -2
- package/test/owner-recovery.test.js +11 -18
- package/test/owner-replacement.test.js +13 -19
- package/test/rollbridge.test.js +242 -13
package/src/process-guardian.js
CHANGED
|
@@ -259,7 +259,7 @@ async function handleLine(socket, line) {
|
|
|
259
259
|
async function execute(request, socket) {
|
|
260
260
|
if (shuttingDown) throw new Error("Process guardian is shutting down")
|
|
261
261
|
|
|
262
|
-
if (request.command === "capabilities") return {daemonRecovery: 1}
|
|
262
|
+
if (request.command === "capabilities") return {daemonRecovery: 1, generationReactivation: 1}
|
|
263
263
|
|
|
264
264
|
if (request.command === "owner-replacement-capabilities") {
|
|
265
265
|
return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
|
|
@@ -655,6 +655,9 @@ async function execute(request, socket) {
|
|
|
655
655
|
await record.process.start(request.reason, request.lifecycleRole)
|
|
656
656
|
} else if (request.command === "activate") {
|
|
657
657
|
await record.process.activateStrict()
|
|
658
|
+
} else if (request.command === "reactivate" || request.command === "reactivate-with-command") {
|
|
659
|
+
await record.process.reactivateStrict()
|
|
660
|
+
record.desired = true
|
|
658
661
|
} else if (request.command === "quiesce") {
|
|
659
662
|
record.desired = false
|
|
660
663
|
await record.process.quiesceStrict()
|
package/src/release-group.js
CHANGED
|
@@ -279,6 +279,28 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
279
279
|
await instance.process.activateStrict()
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/** Restores the retained generation coordinator to its active role in place. */
|
|
283
|
+
async reactivateGeneration() {
|
|
284
|
+
if (this.state !== "draining" && this.state !== "active") throw new Error(`Generation ${this.releaseId} is not retained for reactivation`)
|
|
285
|
+
const processConfig = this.config.processes.find((candidate) => candidate.lifecycle.activateCommand !== undefined)
|
|
286
|
+
|
|
287
|
+
if (!processConfig) throw new Error(`Generation ${this.releaseId} has no activation lifecycle`)
|
|
288
|
+
const [coordinator] = this.getProcesses(processConfig.id)
|
|
289
|
+
|
|
290
|
+
if (!coordinator) throw new Error(`Generation activation process ${processConfig.id} is not retained for release ${this.releaseId}`)
|
|
291
|
+
const generationIds = new Set([...this.handoffServiceIds, ...this.nonBlockingDrainIds])
|
|
292
|
+
|
|
293
|
+
for (const [id, processInstance] of this.processes) {
|
|
294
|
+
if (generationIds.has(id) && processInstance !== coordinator.process) await processInstance.reactivateStrict()
|
|
295
|
+
}
|
|
296
|
+
await coordinator.process.reactivateStrict()
|
|
297
|
+
this.state = "active"
|
|
298
|
+
this.activatedAt = new Date().toISOString()
|
|
299
|
+
this.drainStartedAt = undefined
|
|
300
|
+
this.retirementError = undefined
|
|
301
|
+
this.stoppedAt = undefined
|
|
302
|
+
}
|
|
303
|
+
|
|
282
304
|
/**
|
|
283
305
|
* Restarts only the exact processes reconstructed for a committed generation.
|
|
284
306
|
* The caller must prove the durable transition identity before using this path.
|
package/test/completion.test.js
CHANGED
|
@@ -41,9 +41,10 @@ test("completion bash prints a sourceable script with commands and option flags"
|
|
|
41
41
|
|
|
42
42
|
assert.notEqual(code, 1)
|
|
43
43
|
assert.match(output, /complete -F _rollbridge rollbridge/)
|
|
44
|
-
assert.match(output, /compgen -W "daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
|
|
44
|
+
assert.match(output, /compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
|
|
45
45
|
// A command's own options are completed after the command.
|
|
46
46
|
assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
|
|
47
|
+
assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id"/)
|
|
47
48
|
assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
|
|
48
49
|
assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
|
|
49
50
|
})
|
|
@@ -53,7 +54,8 @@ test("completion zsh prints a #compdef script with per-command options", async (
|
|
|
53
54
|
|
|
54
55
|
assert.match(output, /^#compdef rollbridge/)
|
|
55
56
|
assert.match(output, /compdef _rollbridge rollbridge/)
|
|
56
|
-
assert.match(output, /commands=\(daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
|
|
57
|
+
assert.match(output, /commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
|
|
58
|
+
assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id/)
|
|
57
59
|
assert.match(output, /events\) compadd -- [^\n]*--limit/)
|
|
58
60
|
})
|
|
59
61
|
|
|
@@ -27,6 +27,7 @@ test("TensorBuzz example config loads", async () => {
|
|
|
27
27
|
["web", "proxied"]
|
|
28
28
|
]
|
|
29
29
|
)
|
|
30
|
+
assert.equal(config.processes[2].lifecycle.reactivateCommand, "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
|
|
30
31
|
assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
|
|
31
32
|
})
|
|
32
33
|
|
|
@@ -180,18 +180,20 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
|
|
|
180
180
|
})
|
|
181
181
|
|
|
182
182
|
// Omitted → no commands, zero drain.
|
|
183
|
-
assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {drainTimeoutMs: 0})
|
|
183
|
+
assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {activateTimeoutMs: 30000, drainTimeoutMs: 0})
|
|
184
184
|
|
|
185
|
-
const custom = validateLifecycle({drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
|
|
185
|
+
const custom = validateLifecycle({activateTimeoutMs: 60000, drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
|
|
186
186
|
|
|
187
187
|
assert.deepEqual(custom.issues, [])
|
|
188
188
|
assert.equal(custom.config.processes[0].lifecycle.quietCommand, "kill -TSTP $ROLLBRIDGE_PID")
|
|
189
189
|
assert.equal(custom.config.processes[0].lifecycle.stopCommand, "kill -TERM $ROLLBRIDGE_PID")
|
|
190
|
+
assert.equal(custom.config.processes[0].lifecycle.activateTimeoutMs, 60000)
|
|
190
191
|
assert.equal(custom.config.processes[0].lifecycle.drainTimeoutMs, 30000)
|
|
191
192
|
|
|
192
|
-
const invalid = validateLifecycle({drainTimeoutMs: -1, quietCommand: 5})
|
|
193
|
+
const invalid = validateLifecycle({activateTimeoutMs: 0, drainTimeoutMs: -1, quietCommand: 5})
|
|
193
194
|
const messages = invalid.issues.map((issue) => issue.message)
|
|
194
195
|
|
|
196
|
+
assert.ok(messages.includes("processes[0].lifecycle.activateTimeoutMs must be a positive number"), JSON.stringify(messages))
|
|
195
197
|
assert.ok(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number"), JSON.stringify(messages))
|
|
196
198
|
assert.ok(messages.includes("processes[0].lifecycle.quietCommand must be a string"), JSON.stringify(messages))
|
|
197
199
|
|
|
@@ -250,6 +252,24 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
|
|
|
250
252
|
{...base.processes[1], id: "jobs-secondary", port: {from: 18200, to: 18299}}
|
|
251
253
|
]})
|
|
252
254
|
assert.ok(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message)))
|
|
255
|
+
|
|
256
|
+
const worker = {
|
|
257
|
+
command: "run worker",
|
|
258
|
+
id: "worker",
|
|
259
|
+
lifecycle: {quietCommand: "worker quiet", reactivateCommand: "worker resume"},
|
|
260
|
+
nonBlockingDrain: true,
|
|
261
|
+
policy: "companion"
|
|
262
|
+
}
|
|
263
|
+
const pairedWorker = validateConfig({...base, processes: [...base.processes, worker]})
|
|
264
|
+
|
|
265
|
+
assert.deepEqual(pairedWorker.issues, [])
|
|
266
|
+
assert.equal(pairedWorker.config.processes[2].lifecycle.reactivateCommand, "worker resume")
|
|
267
|
+
|
|
268
|
+
const unpairedWorker = validateConfig({...base, processes: [...base.processes, {...worker, lifecycle: {quietCommand: "worker quiet"}}]})
|
|
269
|
+
assert.ok(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message)))
|
|
270
|
+
|
|
271
|
+
const unsupportedPlacement = validateConfig({...base, processes: [...base.processes, {...worker, nonBlockingDrain: false}]})
|
|
272
|
+
assert.ok(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message)))
|
|
253
273
|
})
|
|
254
274
|
|
|
255
275
|
test("validateConfig accepts indefinite graceful stop windows", () => {
|
|
@@ -18,7 +18,7 @@ test("guardian bootstrap capability is absent from process argv", async () => {
|
|
|
18
18
|
const fixture = await createGuardian()
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
assert.deepEqual(await fixture.client.capabilities(), {daemonRecovery: 1})
|
|
21
|
+
assert.deepEqual(await fixture.client.capabilities(), {daemonRecovery: 1, generationReactivation: 1})
|
|
22
22
|
const commandLine = await fs.readFile(`/proc/${fixture.client.pid}/cmdline`, "utf8")
|
|
23
23
|
const environment = await fs.readFile(`/proc/${fixture.client.pid}/environ`, "utf8")
|
|
24
24
|
const status = await fs.readFile(`/proc/${fixture.client.pid}/status`, "utf8")
|
|
@@ -70,6 +70,87 @@ test("guardian runs a strict activation lifecycle command for the exact register
|
|
|
70
70
|
}
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
test("client reactivates a retained process through a guardian without the reactivation command", async () => {
|
|
74
|
+
const fixture = await createGuardian()
|
|
75
|
+
const lifecyclePath = path.join(fixture.root, "lifecycle.log")
|
|
76
|
+
const processInstance = fixture.client.process("compatible-reactivation", {
|
|
77
|
+
...definition("compatible-reactivation"),
|
|
78
|
+
lifecycle: {
|
|
79
|
+
activateCommand: `printf 'activate\n' >> ${JSON.stringify(lifecyclePath)}`,
|
|
80
|
+
drainTimeoutMs: 0,
|
|
81
|
+
quietCommand: `printf 'retire\n' >> ${JSON.stringify(lifecyclePath)}`
|
|
82
|
+
},
|
|
83
|
+
shouldRestart: () => true
|
|
84
|
+
})
|
|
85
|
+
const request = fixture.client.request.bind(fixture.client)
|
|
86
|
+
|
|
87
|
+
fixture.client.request = async command => {
|
|
88
|
+
if (command.command === "reactivate") throw new Error("Unknown guardian command: reactivate")
|
|
89
|
+
return await request(command)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await processInstance.start()
|
|
94
|
+
await processInstance.activateStrict()
|
|
95
|
+
const pid = processInstance.status().pid
|
|
96
|
+
|
|
97
|
+
await processInstance.quiesceStrict()
|
|
98
|
+
await processInstance.reactivateStrict()
|
|
99
|
+
|
|
100
|
+
assert.equal(processInstance.status().pid, pid)
|
|
101
|
+
assert.equal(processInstance.status().state, "running")
|
|
102
|
+
assert.equal(processInstance.status().lifecycleRole, "active")
|
|
103
|
+
assert.equal(await fs.readFile(lifecyclePath, "utf8"), "activate\nretire\nactivate\n")
|
|
104
|
+
|
|
105
|
+
const restarted = once(processInstance, "started")
|
|
106
|
+
|
|
107
|
+
assert.ok(pid)
|
|
108
|
+
process.kill(-pid, "SIGKILL")
|
|
109
|
+
await restarted
|
|
110
|
+
assert.notEqual(processInstance.status().pid, pid)
|
|
111
|
+
assert.equal(processInstance.status().state, "running")
|
|
112
|
+
assert.equal(processInstance.status().lifecycleRole, "active")
|
|
113
|
+
assert.equal(await fs.readFile(lifecyclePath, "utf8"), "activate\nretire\nactivate\nactivate\n")
|
|
114
|
+
} finally {
|
|
115
|
+
await cleanupGuardian(fixture)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("client reverses a worker quiet hook through a pre-reactivation guardian", async () => {
|
|
120
|
+
const fixture = await createGuardian()
|
|
121
|
+
const lifecyclePath = path.join(fixture.root, "worker-lifecycle.log")
|
|
122
|
+
const processInstance = fixture.client.process("compatible-worker-reactivation", {
|
|
123
|
+
...definition("compatible-worker-reactivation"),
|
|
124
|
+
lifecycle: {
|
|
125
|
+
drainTimeoutMs: 0,
|
|
126
|
+
quietCommand: `printf 'quiet\n' >> ${JSON.stringify(lifecyclePath)}`,
|
|
127
|
+
reactivateCommand: `printf 'resume\n' >> ${JSON.stringify(lifecyclePath)}`
|
|
128
|
+
},
|
|
129
|
+
shouldRestart: () => true
|
|
130
|
+
})
|
|
131
|
+
const request = fixture.client.request.bind(fixture.client)
|
|
132
|
+
|
|
133
|
+
fixture.client.request = async command => {
|
|
134
|
+
if (command.command === "reactivate-with-command") throw new Error("Unknown guardian command: reactivate-with-command")
|
|
135
|
+
return await request(command)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await processInstance.start()
|
|
140
|
+
const pid = processInstance.status().pid
|
|
141
|
+
|
|
142
|
+
await processInstance.quiesceStrict()
|
|
143
|
+
await processInstance.reactivateStrict()
|
|
144
|
+
|
|
145
|
+
assert.equal(processInstance.status().pid, pid)
|
|
146
|
+
assert.equal(processInstance.status().state, "running")
|
|
147
|
+
assert.equal(processInstance.status().lifecycleRole, "active")
|
|
148
|
+
assert.equal(await fs.readFile(lifecyclePath, "utf8"), "quiet\nresume\n")
|
|
149
|
+
} finally {
|
|
150
|
+
await cleanupGuardian(fixture)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
73
154
|
test("guardian atomically updates process provenance with private owner state", async () => {
|
|
74
155
|
const fixture = await createGuardian()
|
|
75
156
|
const processInstance = fixture.client.process("service", definition("service"))
|
|
@@ -450,14 +450,14 @@ test("activateStrict runs the configured activation command once per call and re
|
|
|
450
450
|
const pid = managed.pid
|
|
451
451
|
|
|
452
452
|
assert.ok(pid)
|
|
453
|
-
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
|
|
453
|
+
managed.lifecycle = {activateCommand: "jobs activate", activateTimeoutMs: 60000, drainTimeoutMs: 0}
|
|
454
454
|
managed.runHook = async (command, timeoutMs, label, hookPid) => {
|
|
455
455
|
commands.push({command, label, pid: hookPid, timeoutMs})
|
|
456
456
|
return undefined
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
await managed.activateStrict()
|
|
460
|
-
assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs:
|
|
460
|
+
assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs: 60000}])
|
|
461
461
|
|
|
462
462
|
managed.runHook = async () => new Error("activation rejected")
|
|
463
463
|
await assert.rejects(() => managed.activateStrict(), /activation rejected/)
|
|
@@ -505,6 +505,33 @@ test("activateStrict rejects an activation request when its process is not runni
|
|
|
505
505
|
assert.equal(hookRan, false)
|
|
506
506
|
})
|
|
507
507
|
|
|
508
|
+
test("reactivateStrict restores a retained quiesced process only after activation succeeds", async () => {
|
|
509
|
+
const managed = buildLongLived(() => false)
|
|
510
|
+
const hooks = /** @type {string[]} */ ([])
|
|
511
|
+
|
|
512
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0, quietCommand: "jobs retire"}
|
|
513
|
+
managed.runHook = async (_command, _timeoutMs, label) => {
|
|
514
|
+
hooks.push(label)
|
|
515
|
+
if (label === "activate command" && hooks.length === 2) return new Error("restoration rejected")
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
await managed.start()
|
|
521
|
+
await managed.quiesceStrict()
|
|
522
|
+
await assert.rejects(() => managed.reactivateStrict(), /restoration rejected/)
|
|
523
|
+
assert.equal(managed.status().state, "quiesced")
|
|
524
|
+
assert.equal(managed.status().lifecycleRole, "retired")
|
|
525
|
+
|
|
526
|
+
await managed.reactivateStrict()
|
|
527
|
+
assert.equal(managed.status().state, "running")
|
|
528
|
+
assert.equal(managed.status().lifecycleRole, "active")
|
|
529
|
+
assert.deepEqual(hooks, ["quiet command", "activate command", "activate command"])
|
|
530
|
+
} finally {
|
|
531
|
+
await managed.stop()
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
|
|
508
535
|
test("quiesce waits for active-role restoration before retiring a restarted process", async () => {
|
|
509
536
|
const managed = buildLongLived(() => false)
|
|
510
537
|
const hooks = /** @type {string[]} */ ([])
|
|
@@ -748,7 +748,7 @@ test("guardian recovery becomes ready before replaying a gated generation hook",
|
|
|
748
748
|
}
|
|
749
749
|
})
|
|
750
750
|
|
|
751
|
-
test("owner recovery preserves a
|
|
751
|
+
test("owner recovery preserves a completed activation compensation without replaying hooks", async () => {
|
|
752
752
|
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
753
753
|
let owner = spawnDaemon(fixture.configPath)
|
|
754
754
|
|
|
@@ -762,7 +762,7 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
762
762
|
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
763
763
|
/activate command exited non-zero/
|
|
764
764
|
)
|
|
765
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
|
|
765
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
766
766
|
|
|
767
767
|
owner.kill("SIGKILL")
|
|
768
768
|
await once(owner, "exit")
|
|
@@ -771,13 +771,9 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
771
771
|
|
|
772
772
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
773
773
|
|
|
774
|
-
assert.equal(recovered.
|
|
775
|
-
assert.
|
|
776
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery
|
|
777
|
-
|
|
778
|
-
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
779
|
-
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
780
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
774
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
775
|
+
assert.equal(recovered.generationTransition, undefined)
|
|
776
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"], "owner recovery must not replay completed compensation hooks")
|
|
781
777
|
|
|
782
778
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
783
779
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -789,17 +785,14 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
789
785
|
}
|
|
790
786
|
})
|
|
791
787
|
|
|
792
|
-
test("owner recovery replays one journaled ambiguous activation by exact
|
|
793
|
-
const fixture = await createFixture({activationFailureRelease: "
|
|
788
|
+
test("owner recovery replays one journaled ambiguous first-generation activation by exact identity", async () => {
|
|
789
|
+
const fixture = await createFixture({activationFailureRelease: "v1"})
|
|
794
790
|
let owner = spawnDaemon(fixture.configPath)
|
|
795
791
|
|
|
796
792
|
try {
|
|
797
793
|
await waitForLog(owner, "control socket listening")
|
|
798
794
|
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
802
|
-
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
|
|
795
|
+
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath}))
|
|
803
796
|
owner.kill("SIGKILL")
|
|
804
797
|
await once(owner, "exit")
|
|
805
798
|
|
|
@@ -818,12 +811,12 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
818
811
|
|
|
819
812
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
820
813
|
|
|
821
|
-
assert.equal(recovered.activeReleaseId, "
|
|
814
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
822
815
|
assert.equal(recovered.generationTransition?.phase, "committed")
|
|
823
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"
|
|
816
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
824
817
|
|
|
825
818
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
826
|
-
await
|
|
819
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
827
820
|
await shutdown
|
|
828
821
|
} finally {
|
|
829
822
|
await killChild(owner)
|
|
@@ -1649,7 +1649,7 @@ test("pruned release connection completion closes the incumbent listener session
|
|
|
1649
1649
|
assert.equal(daemon.incumbentListenerControl, session)
|
|
1650
1650
|
})
|
|
1651
1651
|
|
|
1652
|
-
test("same-authority owner replacement preserves
|
|
1652
|
+
test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
|
|
1653
1653
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
|
|
1654
1654
|
const oldSocketPath = path.join(root, "old.sock")
|
|
1655
1655
|
const statePath = path.join(root, "state.json")
|
|
@@ -1677,18 +1677,15 @@ test("same-authority owner replacement preserves a failed generation transition
|
|
|
1677
1677
|
await waitForLog(owner, "control socket listening")
|
|
1678
1678
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
1679
1679
|
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
|
|
1680
|
-
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
|
|
1680
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1681
1681
|
|
|
1682
1682
|
await writeConfig(configPath, failedConfig(oldSocketPath, false))
|
|
1683
1683
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1684
1684
|
await waitForLog(candidate, "owner replacement committed")
|
|
1685
1685
|
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
assert.
|
|
1689
|
-
assert.equal(generationTransition.phase, "activating_candidate")
|
|
1690
|
-
assert.match(String(generationTransition.error), /activate command exited non-zero/)
|
|
1691
|
-
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
|
|
1686
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
1687
|
+
assert.equal(status.generationTransition, undefined)
|
|
1688
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n", "replacement must not replay completed compensation hooks")
|
|
1692
1689
|
|
|
1693
1690
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1694
1691
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -1700,7 +1697,7 @@ test("same-authority owner replacement preserves a failed generation transition
|
|
|
1700
1697
|
}
|
|
1701
1698
|
})
|
|
1702
1699
|
|
|
1703
|
-
test("config-changing owner replacement
|
|
1700
|
+
test("config-changing owner replacement proceeds after activation compensation clears the transition", async () => {
|
|
1704
1701
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-unresolved-config-"))
|
|
1705
1702
|
const oldSocketPath = path.join(root, "old.sock")
|
|
1706
1703
|
const newSocketPath = path.join(root, "new.sock")
|
|
@@ -1734,16 +1731,13 @@ test("config-changing owner replacement rejects an unresolved generation transit
|
|
|
1734
1731
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1735
1732
|
const result = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1736
1733
|
|
|
1737
|
-
assert.equal(result.message,
|
|
1738
|
-
|
|
1739
|
-
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1740
|
-
const generationTransition = status.generationTransition
|
|
1734
|
+
assert.equal(result.message, "owner replacement committed", result.output)
|
|
1735
|
+
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1741
1736
|
|
|
1742
|
-
assert.
|
|
1743
|
-
assert.equal(generationTransition
|
|
1744
|
-
assert.
|
|
1745
|
-
|
|
1746
|
-
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1737
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
1738
|
+
assert.equal(status.generationTransition, undefined)
|
|
1739
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1740
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1747
1741
|
|
|
1748
1742
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
1749
1743
|
await shutdown
|
|
@@ -1928,7 +1922,7 @@ async function removeDaemonRecoveryCapability(packagePath, {abortedPath, prepare
|
|
|
1928
1922
|
const guardianPath = path.join(packagePath, "src", "process-guardian.js")
|
|
1929
1923
|
const daemonPath = path.join(packagePath, "src", "daemon.js")
|
|
1930
1924
|
const source = await fs.readFile(guardianPath, "utf8")
|
|
1931
|
-
const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1}\n\n"
|
|
1925
|
+
const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1, generationReactivation: 1}\n\n"
|
|
1932
1926
|
const incumbentAbortNotification = " if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: \"replacement-aborted\", reason})}\\n`)\n"
|
|
1933
1927
|
const legacyCapability = ` if (request.command === "capabilities") {
|
|
1934
1928
|
while (!fsSync.existsSync(${JSON.stringify(preparedPath)})) await new Promise((resolve) => setTimeout(resolve, 5))
|