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
|
@@ -66,6 +66,21 @@ test("keeps every output line when fewer than the retention limit are produced",
|
|
|
66
66
|
assert.deepEqual(logs.map((entry) => entry.line), ["one", "two"])
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
+
test("emits each output line after retaining it", () => {
|
|
70
|
+
const managed = buildProcess(50)
|
|
71
|
+
let observed
|
|
72
|
+
|
|
73
|
+
managed.once("log", (entry) => {
|
|
74
|
+
observed = {entry, retained: managed.status().logs}
|
|
75
|
+
})
|
|
76
|
+
managed.appendLog("stdout", "ready\n")
|
|
77
|
+
|
|
78
|
+
assert.deepEqual(observed, {
|
|
79
|
+
entry: managed.status().logs[0],
|
|
80
|
+
retained: managed.status().logs
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
69
84
|
test("reports zeroed restart and uptime fields before the process starts", () => {
|
|
70
85
|
const status = buildProcess(50).status()
|
|
71
86
|
|
|
@@ -385,6 +400,24 @@ test("a hanging lifecycle hook is bounded so stop still completes", async () =>
|
|
|
385
400
|
}
|
|
386
401
|
})
|
|
387
402
|
|
|
403
|
+
test("activateStrict runs the configured activation command once per call and rejects failures", async () => {
|
|
404
|
+
const commands = /** @type {{command: string, label: string, pid: number | undefined, timeoutMs: number}[]} */ ([])
|
|
405
|
+
const managed = buildLongLived(() => false)
|
|
406
|
+
|
|
407
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
|
|
408
|
+
managed.pid = 4321
|
|
409
|
+
managed.runHook = async (command, timeoutMs, label, pid) => {
|
|
410
|
+
commands.push({command, label, pid, timeoutMs})
|
|
411
|
+
return undefined
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
await managed.activateStrict()
|
|
415
|
+
assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid: 4321, timeoutMs: 30000}])
|
|
416
|
+
|
|
417
|
+
managed.runHook = async () => new Error("activation rejected")
|
|
418
|
+
await assert.rejects(() => managed.activateStrict(), /activation rejected/)
|
|
419
|
+
})
|
|
420
|
+
|
|
388
421
|
test("sends the configured stopSignal as the graceful stop signal", async () => {
|
|
389
422
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rollbridge-stop-signal-"))
|
|
390
423
|
const readyPath = path.join(dir, "ready")
|
|
@@ -20,7 +20,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
|
20
20
|
const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
|
|
21
21
|
|
|
22
22
|
/** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
|
|
23
|
-
/** @typedef {DaemonStatus & {recovery: {configDigest: string}}} RecoveryState */
|
|
23
|
+
/** @typedef {DaemonStatus & {recovery: {configDigest: string}, singletonReleaseIds?: Record<string, string>}} RecoveryState */
|
|
24
24
|
|
|
25
25
|
test("external owner retirement releases guardian authority without losing its generation", async () => {
|
|
26
26
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
|
|
@@ -172,6 +172,320 @@ test("replacement owner reconstructs one active and two draining generations aft
|
|
|
172
172
|
}
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
+
test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
|
|
176
|
+
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
177
|
+
let owner = spawnDaemon(fixture.configPath)
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await waitForLog(owner, "control socket listening")
|
|
181
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
182
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
183
|
+
|
|
184
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
187
|
+
/activate command exited non-zero/
|
|
188
|
+
)
|
|
189
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
|
|
190
|
+
|
|
191
|
+
owner.kill("SIGKILL")
|
|
192
|
+
await once(owner, "exit")
|
|
193
|
+
owner = spawnDaemon(fixture.configPath)
|
|
194
|
+
await waitForLog(owner, "control socket listening")
|
|
195
|
+
|
|
196
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
197
|
+
|
|
198
|
+
assert.equal(recovered.generationTransition?.phase, "activating_candidate")
|
|
199
|
+
assert.match(String(recovered.generationTransition?.error), /activate command exited non-zero/)
|
|
200
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery alone must not retry a failed hook")
|
|
201
|
+
|
|
202
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
203
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
204
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
205
|
+
|
|
206
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
207
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
208
|
+
await shutdown
|
|
209
|
+
} finally {
|
|
210
|
+
await killChild(owner)
|
|
211
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
212
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("owner recovery replays one journaled ambiguous activation by exact generation identity", async () => {
|
|
217
|
+
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
218
|
+
let owner = spawnDaemon(fixture.configPath)
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await waitForLog(owner, "control socket listening")
|
|
222
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
223
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
224
|
+
|
|
225
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
226
|
+
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
|
|
227
|
+
owner.kill("SIGKILL")
|
|
228
|
+
await once(owner, "exit")
|
|
229
|
+
|
|
230
|
+
const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
231
|
+
|
|
232
|
+
delete ambiguous.generationTransition.error
|
|
233
|
+
await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
|
|
234
|
+
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
235
|
+
owner = spawnDaemon(fixture.configPath)
|
|
236
|
+
await waitForLog(owner, "control socket listening")
|
|
237
|
+
|
|
238
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
239
|
+
|
|
240
|
+
assert.equal(recovered.activeReleaseId, "v2")
|
|
241
|
+
assert.equal(recovered.generationTransition?.phase, "committed")
|
|
242
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
243
|
+
|
|
244
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
245
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
246
|
+
await shutdown
|
|
247
|
+
} finally {
|
|
248
|
+
await killChild(owner)
|
|
249
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
250
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("owner recovery preserves exact release definitions across a candidate config authority change", async () => {
|
|
255
|
+
const fixture = await createFixture()
|
|
256
|
+
const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
|
|
257
|
+
const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
|
|
258
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
259
|
+
let recovered
|
|
260
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
261
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await owner.start()
|
|
265
|
+
await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
266
|
+
const changedConfig = structuredClone(fixture.config)
|
|
267
|
+
const processes = Array.isArray(changedConfig.processes) ? changedConfig.processes : []
|
|
268
|
+
const jobsValue = processes.find((processConfig) => processConfig && typeof processConfig === "object" && !Array.isArray(processConfig) && processConfig.id === "jobs")
|
|
269
|
+
|
|
270
|
+
assert.ok(jobsValue && typeof jobsValue === "object" && !Array.isArray(jobsValue))
|
|
271
|
+
const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobsValue)
|
|
272
|
+
|
|
273
|
+
jobs.env = {.../** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs.env), RELEASE_CONFIG_AUTHORITY: "v2"}
|
|
274
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
275
|
+
|
|
276
|
+
// Simulate an abrupt owner loss at the durable candidate_ready boundary, before
|
|
277
|
+
// retirement refreshes the previous generation's guardian definition.
|
|
278
|
+
owner.resumeGenerationTransition = async () => ({pausedAt: "candidate_ready"})
|
|
279
|
+
await owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
280
|
+
assert.equal(owner.status().generationTransition?.phase, "candidate_ready")
|
|
281
|
+
|
|
282
|
+
await owner.retireCommittedOwner(undefined)
|
|
283
|
+
owner.guardian?.disconnect()
|
|
284
|
+
|
|
285
|
+
recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
|
|
286
|
+
await recovered.start()
|
|
287
|
+
|
|
288
|
+
assert.equal(recovered.status().activeReleaseId, "v2")
|
|
289
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
290
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
291
|
+
} finally {
|
|
292
|
+
if (recovered) {
|
|
293
|
+
const shutdown = recovered.shutdown()
|
|
294
|
+
|
|
295
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
|
|
296
|
+
await shutdown.catch(() => {})
|
|
297
|
+
}
|
|
298
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
299
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test("owner recovery reconstructs a stopped release that still owns a committed-pending singleton", async () => {
|
|
304
|
+
const fixture = await createFixture()
|
|
305
|
+
|
|
306
|
+
fixture.config.releaseRetention = {keep: 0, maxAgeMs: 0}
|
|
307
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
308
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
309
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
310
|
+
const replacementPause = pauseSingletonReplacement(owner, "v2")
|
|
311
|
+
const capturedDrain = captureReleaseDrain(owner, "v1")
|
|
312
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
313
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
314
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
315
|
+
let deployPromise
|
|
316
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
317
|
+
let recovered
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await owner.start()
|
|
321
|
+
await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
322
|
+
deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
323
|
+
void deployPromise.catch(() => {})
|
|
324
|
+
await replacementPause.started
|
|
325
|
+
await capturedDrain.started
|
|
326
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
327
|
+
await capturedDrain.completed
|
|
328
|
+
if (owner.pendingWrite) await owner.pendingWrite
|
|
329
|
+
const pending = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
|
|
330
|
+
|
|
331
|
+
assert.equal(pending.activeReleaseId, "v2")
|
|
332
|
+
assert.equal(pending.generationTransition?.phase, "committed_pending")
|
|
333
|
+
assert.equal(pending.singletonReleaseIds?.singleton, "v1")
|
|
334
|
+
const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
|
|
335
|
+
|
|
336
|
+
assert.ok(stoppedRelease)
|
|
337
|
+
assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
|
|
338
|
+
await owner.retireCommittedOwner(undefined)
|
|
339
|
+
owner.guardian?.disconnect()
|
|
340
|
+
|
|
341
|
+
recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
342
|
+
await recovered.start()
|
|
343
|
+
|
|
344
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
345
|
+
assert.equal(recovered.singletonReleaseIds.get("singleton"), "v2")
|
|
346
|
+
assert.ok(!recovered.releases.has("v1"), "the stopped singleton owner may be pruned after replacement commits")
|
|
347
|
+
assert.equal(recovered.portReservations.has(stoppedRelease.ports.jobs), false)
|
|
348
|
+
assert.equal(recovered.portReservations.has(stoppedRelease.ports.web), false)
|
|
349
|
+
} finally {
|
|
350
|
+
replacementPause.continue()
|
|
351
|
+
await deployPromise?.catch(() => {})
|
|
352
|
+
if (recovered) {
|
|
353
|
+
const shutdown = recovered.shutdown()
|
|
354
|
+
|
|
355
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
356
|
+
await shutdown.catch(() => {})
|
|
357
|
+
recovered.guardian?.disconnect()
|
|
358
|
+
} else {
|
|
359
|
+
const shutdown = owner.shutdown()
|
|
360
|
+
|
|
361
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
362
|
+
await shutdown.catch(() => {})
|
|
363
|
+
owner.guardian?.disconnect()
|
|
364
|
+
}
|
|
365
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
366
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
|
|
371
|
+
const fixture = await createFixture()
|
|
372
|
+
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
373
|
+
const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
|
|
374
|
+
|
|
375
|
+
assert.ok(initialSingleton)
|
|
376
|
+
initialSingleton.env = {SINGLETON_CONFIG_AUTHORITY: "v1"}
|
|
377
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
378
|
+
const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
|
|
379
|
+
const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
|
|
380
|
+
const replacementPause = pauseSingletonReplacement(owner, "v2")
|
|
381
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
382
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
383
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
384
|
+
let deployPromise
|
|
385
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
386
|
+
let recovered
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
await owner.start()
|
|
390
|
+
await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
391
|
+
const changedConfig = structuredClone(fixture.config)
|
|
392
|
+
const changedProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes)
|
|
393
|
+
const changedSingleton = changedProcesses.find((processConfig) => processConfig.id === "singleton")
|
|
394
|
+
|
|
395
|
+
assert.ok(changedSingleton)
|
|
396
|
+
changedSingleton.env = {SINGLETON_CONFIG_AUTHORITY: "v2"}
|
|
397
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
398
|
+
deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
399
|
+
void deployPromise.catch(() => {})
|
|
400
|
+
await replacementPause.started
|
|
401
|
+
const pending = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
|
|
402
|
+
|
|
403
|
+
assert.equal(pending.activeReleaseId, "v2")
|
|
404
|
+
assert.equal(pending.generationTransition?.phase, "committed_pending")
|
|
405
|
+
assert.equal(pending.singletonReleaseIds?.singleton, "v1")
|
|
406
|
+
await owner.retireCommittedOwner(undefined)
|
|
407
|
+
owner.guardian?.disconnect()
|
|
408
|
+
|
|
409
|
+
const nextConfig = normalizeConfig(changedConfig, fixture.configPath)
|
|
410
|
+
|
|
411
|
+
recovered = new RollbridgeDaemon({config: nextConfig, configPath: fixture.configPath, logger: () => {}})
|
|
412
|
+
await recovered.start()
|
|
413
|
+
|
|
414
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
415
|
+
assert.equal(recovered.singletonReleaseIds.get("singleton"), "v2")
|
|
416
|
+
assert.equal(recovered.singletons.get("singleton")?.env.SINGLETON_CONFIG_AUTHORITY, "v2")
|
|
417
|
+
} finally {
|
|
418
|
+
replacementPause.continue()
|
|
419
|
+
await deployPromise?.catch(() => {})
|
|
420
|
+
if (recovered) {
|
|
421
|
+
const shutdown = recovered.shutdown()
|
|
422
|
+
|
|
423
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
|
|
424
|
+
await shutdown.catch(() => {})
|
|
425
|
+
recovered.guardian?.disconnect()
|
|
426
|
+
} else {
|
|
427
|
+
const shutdown = owner.shutdown()
|
|
428
|
+
|
|
429
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
|
|
430
|
+
await shutdown.catch(() => {})
|
|
431
|
+
owner.guardian?.disconnect()
|
|
432
|
+
}
|
|
433
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
434
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
|
|
439
|
+
const fixture = await createFixture()
|
|
440
|
+
const retirementGatePath = path.join(fixture.root, "retirement.allow")
|
|
441
|
+
const retirementWaitingPath = path.join(fixture.root, "retirement.waiting")
|
|
442
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
443
|
+
const jobs = processes.find((processConfig) => processConfig.id === "jobs")
|
|
444
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
|
|
445
|
+
let owner
|
|
446
|
+
|
|
447
|
+
lifecycle.quietCommand = `printf 'waiting\n' >> ${JSON.stringify(retirementWaitingPath)}; while [ ! -f ${JSON.stringify(retirementGatePath)} ]; do sleep 0.02; done; printf 'retire:%s\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(fixture.lifecycleLogPath)}`
|
|
448
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
449
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
450
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
owner = spawnDaemon(fixture.configPath)
|
|
454
|
+
await waitForLog(owner, "control socket listening")
|
|
455
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
456
|
+
const changedConfig = structuredClone(fixture.config)
|
|
457
|
+
const changedProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes)
|
|
458
|
+
const changedJobs = changedProcesses.find((processConfig) => processConfig.id === "jobs")
|
|
459
|
+
|
|
460
|
+
assert.ok(changedJobs)
|
|
461
|
+
changedJobs.env = {.../** @type {Record<string, import("../src/json.js").JsonValue>} */ (changedJobs.env), RELEASE_CONFIG_AUTHORITY: "v2"}
|
|
462
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
463
|
+
const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
464
|
+
void interruptedDeploy.catch(() => {})
|
|
465
|
+
await waitForFile(retirementWaitingPath)
|
|
466
|
+
|
|
467
|
+
owner.kill("SIGKILL")
|
|
468
|
+
await once(owner, "exit")
|
|
469
|
+
await fs.writeFile(retirementGatePath, "release retirement\n")
|
|
470
|
+
|
|
471
|
+
owner = spawnDaemon(fixture.configPath)
|
|
472
|
+
|
|
473
|
+
await waitForLog(owner, "control socket listening")
|
|
474
|
+
assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
|
|
475
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
|
|
476
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
|
|
477
|
+
|
|
478
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
479
|
+
|
|
480
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
|
|
481
|
+
await shutdown
|
|
482
|
+
} finally {
|
|
483
|
+
await killChild(owner)
|
|
484
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
485
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
|
|
175
489
|
test("owner recovery rejects config identity mismatch without changing the valid snapshot", async () => {
|
|
176
490
|
const fixture = await createFixture()
|
|
177
491
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -427,8 +741,9 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
|
|
|
427
741
|
|
|
428
742
|
try {
|
|
429
743
|
await waitForLog(owner, "control socket listening")
|
|
430
|
-
const changedConfig = {
|
|
744
|
+
const changedConfig = /** @type {import("../src/config.js").RollbridgeConfig} */ (structuredClone(fixture.config))
|
|
431
745
|
delete changedConfig.ownerRecovery
|
|
746
|
+
for (const processConfig of changedConfig.processes) if (processConfig.lifecycle) delete processConfig.lifecycle.activateCommand
|
|
432
747
|
await writeConfig(fixture.configPath, changedConfig)
|
|
433
748
|
|
|
434
749
|
await assert.rejects(
|
|
@@ -608,13 +923,18 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
|
|
|
608
923
|
}
|
|
609
924
|
})
|
|
610
925
|
|
|
611
|
-
/**
|
|
612
|
-
|
|
926
|
+
/**
|
|
927
|
+
* @param {{activationFailureRelease?: string}} [options] - Lifecycle fault injection.
|
|
928
|
+
* @returns {Promise<{activationGatePath: string, config: Record<string, import("../src/json.js").JsonValue>, configPath: string, lifecycleLogPath: string, root: string, socketPath: string, statePath: string}>} Fixture paths.
|
|
929
|
+
*/
|
|
930
|
+
async function createFixture(options = {}) {
|
|
613
931
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-recovery-"))
|
|
614
932
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
615
933
|
const configPath = path.join(root, "rollbridge.cjs")
|
|
616
934
|
const statePath = path.join(root, "rollbridge.state.json")
|
|
617
935
|
const serviceLogPath = path.join(root, "service.log")
|
|
936
|
+
const lifecycleLogPath = path.join(root, "generation.lifecycle")
|
|
937
|
+
const activationGatePath = path.join(root, "activation.allow")
|
|
618
938
|
const config = {
|
|
619
939
|
application: "owner-recovery-test",
|
|
620
940
|
control: {path: socketPath},
|
|
@@ -636,7 +956,10 @@ async function createFixture() {
|
|
|
636
956
|
ROLLBRIDGE_SERVICE_LOG: serviceLogPath
|
|
637
957
|
},
|
|
638
958
|
id: "jobs",
|
|
639
|
-
lifecycle: {
|
|
959
|
+
lifecycle: {
|
|
960
|
+
activateCommand: `${options.activationFailureRelease ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(options.activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
|
|
961
|
+
quietCommand: `printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
|
|
962
|
+
},
|
|
640
963
|
policy: "service",
|
|
641
964
|
port: {from: 17000, to: 17020}
|
|
642
965
|
},
|
|
@@ -666,7 +989,15 @@ async function createFixture() {
|
|
|
666
989
|
|
|
667
990
|
await writeConfig(configPath, config)
|
|
668
991
|
|
|
669
|
-
return {config, configPath, root, socketPath, statePath}
|
|
992
|
+
return {activationGatePath, config, configPath, lifecycleLogPath, root, socketPath, statePath}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* @param {string} lifecycleLogPath - Fixture lifecycle log.
|
|
997
|
+
* @returns {Promise<string[]>} Ordered lifecycle events.
|
|
998
|
+
*/
|
|
999
|
+
async function lifecycleEvents(lifecycleLogPath) {
|
|
1000
|
+
return (await fs.readFile(lifecycleLogPath, "utf8")).trim().split("\n").filter(Boolean)
|
|
670
1001
|
}
|
|
671
1002
|
|
|
672
1003
|
/**
|
|
@@ -694,6 +1025,65 @@ async function writeConfig(configPath, config) {
|
|
|
694
1025
|
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
695
1026
|
}
|
|
696
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* Pauses only one candidate's post-commit singleton replacement at an exact promise boundary.
|
|
1030
|
+
* @param {RollbridgeDaemon} daemon - Current owner.
|
|
1031
|
+
* @param {string} releaseId - Candidate release to pause.
|
|
1032
|
+
* @returns {{continue: () => void, started: Promise<void>}} Pause controls.
|
|
1033
|
+
*/
|
|
1034
|
+
function pauseSingletonReplacement(daemon, releaseId) {
|
|
1035
|
+
const replaceSingletons = daemon.replaceSingletons.bind(daemon)
|
|
1036
|
+
/** @type {() => void} */
|
|
1037
|
+
let markStarted = () => {}
|
|
1038
|
+
/** @type {() => void} */
|
|
1039
|
+
let continueReplacement = () => {}
|
|
1040
|
+
const started = new Promise((resolve) => { markStarted = () => resolve(undefined) })
|
|
1041
|
+
const gate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
|
|
1042
|
+
|
|
1043
|
+
daemon.replaceSingletons = async (release) => {
|
|
1044
|
+
if (release.releaseId === releaseId) {
|
|
1045
|
+
markStarted()
|
|
1046
|
+
await gate
|
|
1047
|
+
}
|
|
1048
|
+
await replaceSingletons(release)
|
|
1049
|
+
}
|
|
1050
|
+
return {continue: continueReplacement, started}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Captures one background release drain through its package-owned promise.
|
|
1055
|
+
* @param {RollbridgeDaemon} daemon - Current owner.
|
|
1056
|
+
* @param {string} releaseId - Release whose drain completion is required.
|
|
1057
|
+
* @returns {{completed: Promise<void>, started: Promise<void>}} Drain boundaries.
|
|
1058
|
+
*/
|
|
1059
|
+
function captureReleaseDrain(daemon, releaseId) {
|
|
1060
|
+
const drainAndPrune = daemon.drainAndPrune.bind(daemon)
|
|
1061
|
+
/** @type {() => void} */
|
|
1062
|
+
let markStarted = () => {}
|
|
1063
|
+
/** @type {(error: Error | string) => void} */
|
|
1064
|
+
let rejectCompleted = () => {}
|
|
1065
|
+
/** @type {() => void} */
|
|
1066
|
+
let resolveCompleted = () => {}
|
|
1067
|
+
const started = new Promise((resolve) => { markStarted = () => resolve(undefined) })
|
|
1068
|
+
const completed = new Promise((resolve, reject) => {
|
|
1069
|
+
resolveCompleted = () => resolve(undefined)
|
|
1070
|
+
rejectCompleted = reject
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
daemon.drainAndPrune = async (release, config) => {
|
|
1074
|
+
if (release.releaseId !== releaseId) return await drainAndPrune(release, config)
|
|
1075
|
+
markStarted()
|
|
1076
|
+
try {
|
|
1077
|
+
await drainAndPrune(release, config)
|
|
1078
|
+
resolveCompleted()
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
rejectCompleted(error instanceof Error ? error : String(error))
|
|
1081
|
+
throw error
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return {completed, started}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
697
1087
|
/**
|
|
698
1088
|
* @param {string} statePath - Fixture state path.
|
|
699
1089
|
* @returns {Promise<void>} Cleanup completion.
|