rollbridge 0.1.38 → 0.1.40
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 +22 -4
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +12 -1
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +16 -2
- package/changelog.d/20260830055159-guardian-daemon-restart.md +2 -0
- package/docs/cli.md +33 -9
- package/docs/config.md +37 -13
- package/docs/logging.md +4 -3
- package/docs/troubleshooting.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +100 -26
- package/src/config.js +14 -6
- package/src/daemon.js +741 -153
- package/src/guardian-client.js +68 -16
- package/src/managed-process.js +55 -8
- package/src/process-guardian.js +731 -43
- package/src/release-group.js +77 -6
- package/test/config-validation.test.js +4 -0
- package/test/fixtures/guardian-recovery-owner.js +86 -0
- package/test/fixtures/pre-split3-process-guardian.js +14 -0
- package/test/guardian-client.test.js +1339 -62
- package/test/managed-process.test.js +136 -7
- package/test/owner-recovery.test.js +814 -52
- package/test/owner-replacement.test.js +526 -23
- package/test/release-group.test.js +19 -2
- package/test/release-runtime-retention.test.js +121 -4
- package/test/rollbridge.test.js +21 -0
- package/test/support/process.js +41 -0
|
@@ -13,6 +13,7 @@ import {normalizeConfig} from "../src/config.js"
|
|
|
13
13
|
import {sendControlCommand} from "../src/control-client.js"
|
|
14
14
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
15
15
|
import GuardianClient from "../src/guardian-client.js"
|
|
16
|
+
import {isProcessRunning, waitForProcessExit} from "./support/process.js"
|
|
16
17
|
|
|
17
18
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
18
19
|
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
@@ -20,7 +21,7 @@ const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
|
20
21
|
const serviceAppPath = path.join(currentDir, "fixtures", "service-app.js")
|
|
21
22
|
|
|
22
23
|
/** @typedef {import("../src/daemon.js").DaemonStatus} DaemonStatus */
|
|
23
|
-
/** @typedef {DaemonStatus & {recovery: {configDigest: string}, singletonReleaseIds?: Record<string, string>}} RecoveryState */
|
|
24
|
+
/** @typedef {DaemonStatus & {recovery: {configDigest: string}, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} RecoveryState */
|
|
24
25
|
|
|
25
26
|
test("external owner retirement releases guardian authority without losing its generation", async () => {
|
|
26
27
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-recovery-"))
|
|
@@ -85,7 +86,7 @@ test("external owner retirement releases guardian authority without losing its g
|
|
|
85
86
|
await waitForFile(path.join(v1Path, "drain-started"), 1000)
|
|
86
87
|
await fs.writeFile(path.join(v1Path, "drained"), "done\n")
|
|
87
88
|
await waitForProcessExit(v1WorkerPid, 1000)
|
|
88
|
-
assert.equal(
|
|
89
|
+
assert.equal(isProcessRunning(v2WorkerPid), true, "the active candidate worker must remain usable while the old generation drains")
|
|
89
90
|
} finally {
|
|
90
91
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "drained"), "done\n").catch(() => {})))
|
|
91
92
|
await replacement?.shutdown().catch(() => {})
|
|
@@ -95,6 +96,327 @@ test("external owner retirement releases guardian authority without losing its g
|
|
|
95
96
|
}
|
|
96
97
|
})
|
|
97
98
|
|
|
99
|
+
test("exact bootstrap restores the committed generation after external owner retirement", async () => {
|
|
100
|
+
const fixture = await createFixture()
|
|
101
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
102
|
+
const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
103
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
104
|
+
let recovered
|
|
105
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
106
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await retired.start()
|
|
110
|
+
await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
111
|
+
await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
112
|
+
const committed = retired.status()
|
|
113
|
+
const v1WorkerPid = releaseProcessPid(committed, "v1", "worker")
|
|
114
|
+
const retiredPids = [
|
|
115
|
+
...(committed.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid) || []),
|
|
116
|
+
...committed.services.map(({process}) => process.pid),
|
|
117
|
+
...committed.singletons.map(({process}) => process.pid)
|
|
118
|
+
].filter((pid) => typeof pid === "number")
|
|
119
|
+
|
|
120
|
+
assert.equal(committed.activeReleaseId, "v2")
|
|
121
|
+
assert.equal(committed.generationTransition?.phase, "committed")
|
|
122
|
+
await retired.retireOwner({attestation: `sha256:${"a".repeat(64)}`})
|
|
123
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
124
|
+
await Promise.all(retiredPids.map((pid) => waitForProcessExit(pid, 3000)))
|
|
125
|
+
|
|
126
|
+
recovered = new RollbridgeDaemon({
|
|
127
|
+
bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
|
|
128
|
+
config,
|
|
129
|
+
configPath: fixture.configPath,
|
|
130
|
+
logger: () => {}
|
|
131
|
+
})
|
|
132
|
+
await recovered.start({exposeControl: false})
|
|
133
|
+
const recoveryOrder = /** @type {string[]} */ ([])
|
|
134
|
+
const recoveredCandidate = recovered.releases.get("v2")
|
|
135
|
+
const recoveredService = recovered.services.get("beacon")
|
|
136
|
+
|
|
137
|
+
assert.ok(recoveredCandidate && recoveredService && recovered.singletons.get("singleton"))
|
|
138
|
+
recovered.serviceReleaseIds.set("beacon", "v1")
|
|
139
|
+
assert.throws(
|
|
140
|
+
() => recovered?.assertCommittedBootstrapRecoveryReady(),
|
|
141
|
+
/service beacon belongs to retained release v1/
|
|
142
|
+
)
|
|
143
|
+
recovered.serviceReleaseIds.set("beacon", "v2")
|
|
144
|
+
const activateGeneration = recoveredCandidate.activateGeneration.bind(recoveredCandidate)
|
|
145
|
+
const startService = recoveredService.start.bind(recoveredService)
|
|
146
|
+
const replaceSingletons = recovered.replaceSingletons.bind(recovered)
|
|
147
|
+
|
|
148
|
+
recoveredCandidate.activateGeneration = async () => {
|
|
149
|
+
recoveryOrder.push("activate")
|
|
150
|
+
await activateGeneration()
|
|
151
|
+
}
|
|
152
|
+
recoveredService.start = async (...args) => {
|
|
153
|
+
assert.equal(recovered?.generationTransition?.phase, "restoring_committed")
|
|
154
|
+
recoveryOrder.push("service")
|
|
155
|
+
await startService(...args)
|
|
156
|
+
}
|
|
157
|
+
recovered.replaceSingletons = async (...args) => {
|
|
158
|
+
recoveryOrder.push("singleton")
|
|
159
|
+
await replaceSingletons(...args)
|
|
160
|
+
}
|
|
161
|
+
await recovered.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
162
|
+
const active = recovered.status()
|
|
163
|
+
|
|
164
|
+
assert.equal(active.activeReleaseId, "v2")
|
|
165
|
+
assert.equal(active.generationTransition?.phase, "committed")
|
|
166
|
+
assert.equal(active.releases.find(({releaseId}) => releaseId === "v1")?.state, "draining")
|
|
167
|
+
assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
|
|
168
|
+
assert.equal(isProcessRunning(v1WorkerPid), true, "the retained previous generation must keep draining")
|
|
169
|
+
assert.equal(active.releases.find(({releaseId}) => releaseId === "v2")?.state, "active")
|
|
170
|
+
assert.ok(active.releases.find(({releaseId}) => releaseId === "v2")?.processes.every(({pid, state}) => typeof pid === "number" && state === "running"))
|
|
171
|
+
assert.ok(active.services.every(({process}) => typeof process.pid === "number" && process.state === "running"))
|
|
172
|
+
assert.ok(active.singletons.every(({process}) => typeof process.pid === "number" && process.state === "running"))
|
|
173
|
+
assert.deepEqual(recoveryOrder, ["service", "activate", "singleton"], "candidate activation must precede post-commit singleton completion")
|
|
174
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2", "retire:v2", "activate:v2"])
|
|
175
|
+
} finally {
|
|
176
|
+
if (recovered) {
|
|
177
|
+
const activeRecovery = recovered.status().activeReleaseId === "v2"
|
|
178
|
+
const shutdown = recovered.shutdown()
|
|
179
|
+
|
|
180
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
181
|
+
if (activeRecovery) await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
182
|
+
await shutdown.catch(() => undefined)
|
|
183
|
+
}
|
|
184
|
+
retired.guardian?.disconnect()
|
|
185
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
186
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("exact bootstrap restores a committed generation after its previous release was pruned", async () => {
|
|
191
|
+
const fixture = await createFixture()
|
|
192
|
+
|
|
193
|
+
fixture.config.processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
194
|
+
.filter((processConfig) => processConfig.id !== "beacon" && processConfig.id !== "singleton")
|
|
195
|
+
const worker = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
196
|
+
.find((processConfig) => processConfig.id === "worker")
|
|
197
|
+
|
|
198
|
+
assert.ok(worker)
|
|
199
|
+
const workerLifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (worker.lifecycle)
|
|
200
|
+
|
|
201
|
+
workerLifecycle.drainTimeoutMs = 500
|
|
202
|
+
fixture.config.releaseRetention = {keep: 0, maxAgeMs: 0}
|
|
203
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
204
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
205
|
+
const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
206
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
207
|
+
let recovered
|
|
208
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
209
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await retired.start()
|
|
213
|
+
await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
214
|
+
await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
215
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
216
|
+
await waitForState(fixture.statePath, (state) => !state.releases.some(({releaseId}) => releaseId === "v1"), AbortSignal.timeout(5000))
|
|
217
|
+
const committed = retired.status()
|
|
218
|
+
const retiredPids = committed.releases.find(({releaseId}) => releaseId === "v2")?.processes
|
|
219
|
+
.map(({pid}) => pid)
|
|
220
|
+
.filter((pid) => typeof pid === "number") || []
|
|
221
|
+
|
|
222
|
+
assert.equal(committed.releases.some(({releaseId}) => releaseId === "v1"), false)
|
|
223
|
+
await retired.retireOwner({attestation: `sha256:${"d".repeat(64)}`})
|
|
224
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
225
|
+
await Promise.all(retiredPids.map((pid) => waitForProcessExit(pid, 3000)))
|
|
226
|
+
|
|
227
|
+
recovered = new RollbridgeDaemon({
|
|
228
|
+
bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
|
|
229
|
+
config,
|
|
230
|
+
configPath: fixture.configPath,
|
|
231
|
+
logger: () => {}
|
|
232
|
+
})
|
|
233
|
+
await recovered.start({exposeControl: false})
|
|
234
|
+
await recovered.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
235
|
+
|
|
236
|
+
assert.equal(recovered.status().activeReleaseId, "v2")
|
|
237
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
238
|
+
} finally {
|
|
239
|
+
if (recovered) {
|
|
240
|
+
const activeRecovery = recovered.status().activeReleaseId === "v2"
|
|
241
|
+
const shutdown = recovered.shutdown()
|
|
242
|
+
|
|
243
|
+
if (activeRecovery) await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
244
|
+
await shutdown.catch(() => undefined)
|
|
245
|
+
}
|
|
246
|
+
retired.guardian?.disconnect()
|
|
247
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
248
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test("journaled committed bootstrap recovery resumes after a restart begins", async () => {
|
|
253
|
+
const fixture = await createFixture()
|
|
254
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
255
|
+
const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
256
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
257
|
+
let interrupted
|
|
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 retired.start()
|
|
265
|
+
await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
266
|
+
await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
267
|
+
const committed = retired.status()
|
|
268
|
+
const v1WorkerPid = releaseProcessPid(committed, "v1", "worker")
|
|
269
|
+
const retiringPids = [
|
|
270
|
+
...(committed.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid) || []),
|
|
271
|
+
...committed.services.map(({process}) => process.pid),
|
|
272
|
+
...committed.singletons.map(({process}) => process.pid)
|
|
273
|
+
].filter((pid) => typeof pid === "number")
|
|
274
|
+
|
|
275
|
+
await retired.retireOwner({attestation: `sha256:${"b".repeat(64)}`})
|
|
276
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
277
|
+
await Promise.all(retiringPids.map((pid) => waitForProcessExit(pid, 3000)))
|
|
278
|
+
|
|
279
|
+
const bootstrap = {releaseId: "v2", releasePath: v2Path, revision: "v2"}
|
|
280
|
+
interrupted = new RollbridgeDaemon({bootstrap, config, configPath: fixture.configPath, logger: () => {}})
|
|
281
|
+
await interrupted.start({exposeControl: false})
|
|
282
|
+
interrupted.assertCommittedBootstrapRecoveryReady()
|
|
283
|
+
await interrupted.updateGenerationTransition("restoring_committed")
|
|
284
|
+
const candidate = interrupted.releases.get("v2")
|
|
285
|
+
|
|
286
|
+
assert.ok(candidate)
|
|
287
|
+
for (const processInstance of interrupted.services.values()) await processInstance.start("deploy")
|
|
288
|
+
await candidate.restartCommittedGeneration()
|
|
289
|
+
await interrupted.checkpointGenerationTransition()
|
|
290
|
+
const restarted = interrupted.status()
|
|
291
|
+
const restartedCandidatePids = restarted.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid)
|
|
292
|
+
const restartedServicePids = restarted.services.map(({process}) => process.pid)
|
|
293
|
+
|
|
294
|
+
assert.equal(restarted.generationTransition?.phase, "restoring_committed")
|
|
295
|
+
assert.ok(restartedCandidatePids?.every((pid) => typeof pid === "number"))
|
|
296
|
+
await interrupted.retireCommittedOwner(undefined)
|
|
297
|
+
interrupted.guardian?.disconnect()
|
|
298
|
+
|
|
299
|
+
recovered = new RollbridgeDaemon({bootstrap, config, configPath: fixture.configPath, logger: () => {}})
|
|
300
|
+
await recovered.start({exposeControl: false})
|
|
301
|
+
const active = recovered.status()
|
|
302
|
+
|
|
303
|
+
assert.equal(active.activeReleaseId, "v2")
|
|
304
|
+
assert.equal(active.generationTransition?.phase, "committed")
|
|
305
|
+
assert.deepEqual(active.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid), restartedCandidatePids)
|
|
306
|
+
assert.deepEqual(active.services.map(({process}) => process.pid), restartedServicePids)
|
|
307
|
+
assert.ok(active.singletons.every(({process}) => typeof process.pid === "number" && process.state === "running"))
|
|
308
|
+
assert.equal(releaseProcessPid(active, "v1", "worker"), v1WorkerPid)
|
|
309
|
+
assert.equal(isProcessRunning(v1WorkerPid), true)
|
|
310
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2", "retire:v2", "activate:v2"])
|
|
311
|
+
} finally {
|
|
312
|
+
if (recovered) {
|
|
313
|
+
const shutdown = recovered.shutdown()
|
|
314
|
+
|
|
315
|
+
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => undefined)))
|
|
316
|
+
await shutdown.catch(() => undefined)
|
|
317
|
+
}
|
|
318
|
+
interrupted?.guardian?.disconnect()
|
|
319
|
+
retired.guardian?.disconnect()
|
|
320
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
321
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test("committed bootstrap tuple mismatches fail closed without singletons", async () => {
|
|
326
|
+
const fixture = await createFixture()
|
|
327
|
+
fixture.config.processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
328
|
+
.filter((processConfig) => processConfig.id !== "singleton")
|
|
329
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
330
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
331
|
+
const retired = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
332
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
333
|
+
let recovered
|
|
334
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
335
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
336
|
+
const wrongPath = await prepareRelease(fixture.root, "wrong")
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await retired.start()
|
|
340
|
+
await retired.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
341
|
+
await retired.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
342
|
+
const committed = retired.status()
|
|
343
|
+
const v1WorkerPid = releaseProcessPid(committed, "v1", "worker")
|
|
344
|
+
const retiringPids = [
|
|
345
|
+
...(committed.releases.find(({releaseId}) => releaseId === "v2")?.processes.map(({pid}) => pid) || []),
|
|
346
|
+
...committed.services.map(({process}) => process.pid)
|
|
347
|
+
].filter((pid) => typeof pid === "number")
|
|
348
|
+
|
|
349
|
+
await retired.retireOwner({attestation: `sha256:${"c".repeat(64)}`})
|
|
350
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
351
|
+
await Promise.all(retiringPids.map((pid) => waitForProcessExit(pid, 3000)))
|
|
352
|
+
recovered = new RollbridgeDaemon({
|
|
353
|
+
bootstrap: {releaseId: "v2", releasePath: v2Path, revision: "v2"},
|
|
354
|
+
config,
|
|
355
|
+
configPath: fixture.configPath,
|
|
356
|
+
logger: () => {}
|
|
357
|
+
})
|
|
358
|
+
await recovered.start({exposeControl: false})
|
|
359
|
+
const owner = recovered
|
|
360
|
+
|
|
361
|
+
await assert.rejects(
|
|
362
|
+
() => owner.deploy({releaseId: "v2", releasePath: wrongPath, revision: "v2"}),
|
|
363
|
+
/only the exact same release, path, revision, and config authority/u
|
|
364
|
+
)
|
|
365
|
+
await assert.rejects(
|
|
366
|
+
() => owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "wrong"}),
|
|
367
|
+
/only the exact same release, path, revision, and config authority/u
|
|
368
|
+
)
|
|
369
|
+
const changedConfig = structuredClone(fixture.config)
|
|
370
|
+
const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes)
|
|
371
|
+
.find((processConfig) => processConfig.id === "jobs")
|
|
372
|
+
|
|
373
|
+
assert.ok(jobs)
|
|
374
|
+
jobs.env = {.../** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs.env), MISMATCHED_AUTHORITY: "true"}
|
|
375
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
376
|
+
await assert.rejects(
|
|
377
|
+
() => owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"}),
|
|
378
|
+
/only the exact same release, path, revision, and config authority/u
|
|
379
|
+
)
|
|
380
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
381
|
+
await assert.rejects(
|
|
382
|
+
() => owner.deploy({releaseId: "wrong", releasePath: wrongPath, revision: "wrong"}),
|
|
383
|
+
/only the exact same release, path, revision, and config authority/u
|
|
384
|
+
)
|
|
385
|
+
const preserved = owner.status()
|
|
386
|
+
|
|
387
|
+
assert.equal(preserved.activeReleaseId, null)
|
|
388
|
+
assert.equal(preserved.generationTransition?.candidateReleaseId, "v2")
|
|
389
|
+
assert.equal(preserved.generationTransition?.phase, "committed")
|
|
390
|
+
assert.equal(preserved.releases.find(({releaseId}) => releaseId === "v2")?.state, "draining")
|
|
391
|
+
assert.equal(releaseProcessPid(preserved, "v1", "worker"), v1WorkerPid)
|
|
392
|
+
assert.equal(isProcessRunning(v1WorkerPid), true)
|
|
393
|
+
assert.equal(preserved.releases.some(({releaseId}) => releaseId === "wrong"), false)
|
|
394
|
+
|
|
395
|
+
await owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
396
|
+
await Promise.all([
|
|
397
|
+
owner.stopRelease("v2"),
|
|
398
|
+
fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
399
|
+
])
|
|
400
|
+
const afterIntentionalStop = await owner.deploy({releaseId: "wrong", releasePath: wrongPath, revision: "wrong"})
|
|
401
|
+
|
|
402
|
+
assert.equal(afterIntentionalStop.activeReleaseId, "wrong")
|
|
403
|
+
assert.equal(owner.status().activeReleaseId, "wrong")
|
|
404
|
+
} finally {
|
|
405
|
+
if (recovered) {
|
|
406
|
+
const activeReleaseId = recovered.status().activeReleaseId
|
|
407
|
+
const shutdown = recovered.shutdown()
|
|
408
|
+
|
|
409
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
410
|
+
if (activeReleaseId === "v2") await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
411
|
+
if (activeReleaseId === "wrong") await fs.writeFile(path.join(wrongPath, "worker.fifo"), "drained\n").catch(() => undefined)
|
|
412
|
+
await shutdown.catch(() => undefined)
|
|
413
|
+
}
|
|
414
|
+
retired.guardian?.disconnect()
|
|
415
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
416
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
98
420
|
test("replacement owner reconstructs one active and two draining generations after abrupt daemon exit", async () => {
|
|
99
421
|
const fixture = await createFixture()
|
|
100
422
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -172,6 +494,260 @@ test("replacement owner reconstructs one active and two draining generations aft
|
|
|
172
494
|
}
|
|
173
495
|
})
|
|
174
496
|
|
|
497
|
+
test("guardian restarts an abruptly exited daemon without replacing managed processes", async () => {
|
|
498
|
+
const fixture = await createFixture()
|
|
499
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
500
|
+
let recoveredDaemonPid
|
|
501
|
+
let workerPid
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
await waitForLog(owner, "control socket listening")
|
|
505
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
506
|
+
|
|
507
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
|
|
508
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
509
|
+
|
|
510
|
+
workerPid = releaseProcessPid(before, "v1", "worker")
|
|
511
|
+
const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
|
|
512
|
+
|
|
513
|
+
owner.kill("SIGKILL")
|
|
514
|
+
await once(owner, "exit")
|
|
515
|
+
await recoveredListenerLog
|
|
516
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
517
|
+
|
|
518
|
+
recoveredDaemonPid = recovered.daemonPid
|
|
519
|
+
assert.notEqual(recovered.daemonPid, before.daemonPid)
|
|
520
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
521
|
+
assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
|
|
522
|
+
|
|
523
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
524
|
+
|
|
525
|
+
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
526
|
+
await shutdown
|
|
527
|
+
} finally {
|
|
528
|
+
await killChild(owner)
|
|
529
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
530
|
+
if (workerPid) {
|
|
531
|
+
try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact managed group already exited. */ }
|
|
532
|
+
}
|
|
533
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
534
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
test("guardian recovers a persistent service after its final active release stops", async () => {
|
|
539
|
+
const fixture = await createFixture()
|
|
540
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
541
|
+
const jobs = processes.find((processConfig) => processConfig.id === "jobs")
|
|
542
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
543
|
+
|
|
544
|
+
assert.ok(jobs && worker)
|
|
545
|
+
jobs.port = {from: 17000, to: 17001}
|
|
546
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
547
|
+
fixture.config.processes = processes.filter((processConfig) => processConfig.id !== "singleton")
|
|
548
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
549
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
550
|
+
let recoveredDaemonPid
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
await waitForLog(owner, "control socket listening")
|
|
554
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
555
|
+
|
|
556
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath, revision: "v1"}, path: fixture.socketPath})
|
|
557
|
+
await sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath})
|
|
558
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
559
|
+
const servicePid = before.services[0]?.process.pid
|
|
560
|
+
|
|
561
|
+
assert.equal(before.activeReleaseId, null)
|
|
562
|
+
assert.equal(typeof servicePid, "number")
|
|
563
|
+
const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
|
|
564
|
+
|
|
565
|
+
assert.equal(persisted.serviceReleaseIds?.beacon, "v1")
|
|
566
|
+
const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
|
|
567
|
+
|
|
568
|
+
owner.kill("SIGKILL")
|
|
569
|
+
await once(owner, "exit")
|
|
570
|
+
await recoveredListenerLog
|
|
571
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
572
|
+
|
|
573
|
+
recoveredDaemonPid = recovered.daemonPid
|
|
574
|
+
assert.equal(recovered.activeReleaseId, null)
|
|
575
|
+
assert.equal(recovered.services[0]?.process.pid, servicePid)
|
|
576
|
+
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
577
|
+
const nextReleasePath = await prepareRelease(fixture.root, "v2")
|
|
578
|
+
|
|
579
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: nextReleasePath, revision: "v2"}, path: fixture.socketPath})
|
|
580
|
+
const finalReleasePath = await prepareRelease(fixture.root, "v3")
|
|
581
|
+
|
|
582
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: finalReleasePath, revision: "v3"}, path: fixture.socketPath})
|
|
583
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v3")
|
|
584
|
+
await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
585
|
+
} finally {
|
|
586
|
+
await killChild(owner)
|
|
587
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
588
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
589
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test("owner state omits a new persistent service until its defining release is retained", async () => {
|
|
594
|
+
const fixture = await createFixture()
|
|
595
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
596
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
597
|
+
const releasePath = await prepareRelease(fixture.root, "v1", {holdJobsBind: true})
|
|
598
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
599
|
+
let deploy
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
await owner.start()
|
|
603
|
+
deploy = owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
604
|
+
await waitForFile(path.join(releasePath, "jobs.bind-waiting"), 3000)
|
|
605
|
+
await owner.persistState({throwOnError: true})
|
|
606
|
+
const persisted = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(fixture.statePath, "utf8")))
|
|
607
|
+
const guardianState = /** @type {Record<string, import("../src/json.js").JsonValue> | undefined} */ (await owner.guardian?.ownerState())
|
|
608
|
+
const guardianSnapshot = /** @type {RecoveryState | undefined} */ (guardianState?.snapshot)
|
|
609
|
+
|
|
610
|
+
assert.deepEqual(persisted.releases, [])
|
|
611
|
+
assert.deepEqual(persisted.services, [])
|
|
612
|
+
assert.deepEqual(persisted.serviceReleaseIds, {})
|
|
613
|
+
assert.deepEqual(guardianSnapshot?.releases, [])
|
|
614
|
+
assert.deepEqual(guardianSnapshot?.services, [])
|
|
615
|
+
assert.deepEqual(guardianState?.serviceReleaseIds, {})
|
|
616
|
+
|
|
617
|
+
await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n")
|
|
618
|
+
await deploy
|
|
619
|
+
assert.equal(owner.status().services.find(({id}) => id === "beacon")?.process.state, "running")
|
|
620
|
+
assert.equal(owner.serviceReleaseIds.get("beacon"), "v1")
|
|
621
|
+
} finally {
|
|
622
|
+
await fs.writeFile(path.join(releasePath, "jobs.bind"), "ready\n").catch(() => {})
|
|
623
|
+
await deploy?.catch(() => {})
|
|
624
|
+
await Promise.all([
|
|
625
|
+
fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}),
|
|
626
|
+
owner.shutdown().catch(() => {})
|
|
627
|
+
])
|
|
628
|
+
owner.guardian?.disconnect()
|
|
629
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
630
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
test("owner recovery accepts released format-2 state without journal revision or service owner metadata", async () => {
|
|
635
|
+
const fixture = await createFixture()
|
|
636
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
637
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
638
|
+
|
|
639
|
+
assert.ok(worker)
|
|
640
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
641
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
642
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
643
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
644
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
645
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
646
|
+
let recovered
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
await owner.start()
|
|
650
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
651
|
+
if (owner.pendingWrite) await owner.pendingWrite
|
|
652
|
+
const publicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
653
|
+
const privateOwnerState = owner.transferableOwnerState()
|
|
654
|
+
const privateSnapshot = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateOwnerState.snapshot)
|
|
655
|
+
const privateTransition = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (privateSnapshot.generationTransition)
|
|
656
|
+
|
|
657
|
+
delete publicState.generationTransition.journalRevision
|
|
658
|
+
delete publicState.serviceReleaseIds
|
|
659
|
+
delete privateTransition.journalRevision
|
|
660
|
+
delete privateOwnerState.serviceReleaseIds
|
|
661
|
+
await owner.guardian?.publishOwnerState(privateOwnerState)
|
|
662
|
+
await fs.writeFile(fixture.statePath, `${JSON.stringify(publicState, null, 2)}\n`)
|
|
663
|
+
await owner.retireCommittedOwner(undefined)
|
|
664
|
+
owner.guardian?.disconnect()
|
|
665
|
+
|
|
666
|
+
recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
667
|
+
await recovered.start()
|
|
668
|
+
|
|
669
|
+
assert.equal(recovered.status().activeReleaseId, "v1")
|
|
670
|
+
assert.equal(recovered.status().generationTransition?.journalRevision, undefined)
|
|
671
|
+
assert.equal(recovered.serviceReleaseIds.get("beacon"), "v1")
|
|
672
|
+
} finally {
|
|
673
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
674
|
+
else await owner.shutdown().catch(() => {})
|
|
675
|
+
owner.guardian?.disconnect()
|
|
676
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
677
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
test("guardian recovery becomes ready before replaying a gated generation hook", async () => {
|
|
682
|
+
const fixture = await createFixture()
|
|
683
|
+
const retirementGatePath = path.join(fixture.root, "retirement.allow")
|
|
684
|
+
const retirementWaitingPath = path.join(fixture.root, "retirement.waiting")
|
|
685
|
+
const daemonPidPath = path.join(fixture.root, "daemon.pid")
|
|
686
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
687
|
+
const jobs = processes.find((processConfig) => processConfig.id === "jobs")
|
|
688
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
689
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
|
|
690
|
+
|
|
691
|
+
assert.ok(jobs && worker)
|
|
692
|
+
jobs.gracefulStopMs = 5000
|
|
693
|
+
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)}`
|
|
694
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
695
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
696
|
+
const owner = spawnDaemon(fixture.configPath, undefined, {daemonPidPath, startupTimeoutMs: 3000})
|
|
697
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
698
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
699
|
+
let recoveredDaemonPid
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
await waitForLog(owner, "control socket listening")
|
|
703
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
704
|
+
const interruptedDeploy = sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
705
|
+
|
|
706
|
+
void interruptedDeploy.catch(() => {})
|
|
707
|
+
await waitForFile(retirementWaitingPath)
|
|
708
|
+
await fs.rm(daemonPidPath, {force: true})
|
|
709
|
+
const recoveredListenerLog = waitForLog(owner, "control socket listening", {allowChildExit: true})
|
|
710
|
+
|
|
711
|
+
owner.kill("SIGKILL")
|
|
712
|
+
await once(owner, "exit")
|
|
713
|
+
await recoveredListenerLog
|
|
714
|
+
await waitForFile(daemonPidPath, 3000)
|
|
715
|
+
recoveredDaemonPid = Number((await fs.readFile(daemonPidPath, "utf8")).trim())
|
|
716
|
+
const recovering = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
717
|
+
const guardianPid = JSON.parse(await fs.readFile(fixture.statePath, "utf8")).recovery?.guardian?.pid
|
|
718
|
+
|
|
719
|
+
assert.equal(recovering.daemonPid, recoveredDaemonPid)
|
|
720
|
+
assert.equal(recovering.ownerRecovery?.ready, true)
|
|
721
|
+
assert.equal(recovering.generationTransition?.phase, "retiring_previous")
|
|
722
|
+
assert.equal(typeof guardianPid, "number")
|
|
723
|
+
await assert.rejects(
|
|
724
|
+
sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: fixture.socketPath}),
|
|
725
|
+
/Another owner mutation/
|
|
726
|
+
)
|
|
727
|
+
await assert.rejects(
|
|
728
|
+
sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}),
|
|
729
|
+
/Cannot shut down while generation transition recovery is in progress/
|
|
730
|
+
)
|
|
731
|
+
await assert.rejects(
|
|
732
|
+
sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: fixture.socketPath}),
|
|
733
|
+
/Cannot retire owner while generation transition recovery is in progress/
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
process.kill(recoveredDaemonPid, "SIGTERM")
|
|
737
|
+
await fs.writeFile(retirementGatePath, "release retirement\n")
|
|
738
|
+
await waitForProcessExit(recoveredDaemonPid, 5000)
|
|
739
|
+
await waitForProcessExit(guardianPid, 5000)
|
|
740
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2", "retire:v2"])
|
|
741
|
+
} finally {
|
|
742
|
+
await fs.writeFile(retirementGatePath, "release retirement\n").catch(() => undefined)
|
|
743
|
+
await sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath}).catch(() => undefined)
|
|
744
|
+
await killChild(owner)
|
|
745
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
746
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
747
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
748
|
+
}
|
|
749
|
+
})
|
|
750
|
+
|
|
175
751
|
test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
|
|
176
752
|
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
177
753
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -230,10 +806,15 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
230
806
|
const ambiguous = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
231
807
|
|
|
232
808
|
delete ambiguous.generationTransition.error
|
|
809
|
+
ambiguous.generationTransition.journalRevision += 1
|
|
233
810
|
await fs.writeFile(fixture.statePath, `${JSON.stringify(ambiguous, null, 2)}\n`)
|
|
234
811
|
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
235
812
|
owner = spawnDaemon(fixture.configPath)
|
|
813
|
+
const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
|
|
814
|
+
|
|
236
815
|
await waitForLog(owner, "control socket listening")
|
|
816
|
+
await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
|
|
817
|
+
await recoverySettled
|
|
237
818
|
|
|
238
819
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
239
820
|
|
|
@@ -251,8 +832,14 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
251
832
|
}
|
|
252
833
|
})
|
|
253
834
|
|
|
254
|
-
test("owner recovery preserves
|
|
835
|
+
test("owner recovery preserves complete private transition authority across a candidate config change", async () => {
|
|
255
836
|
const fixture = await createFixture()
|
|
837
|
+
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
838
|
+
const initialWorker = initialProcesses.find((processConfig) => processConfig.id === "worker")
|
|
839
|
+
|
|
840
|
+
assert.ok(initialWorker)
|
|
841
|
+
initialWorker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
842
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
256
843
|
const initialConfig = normalizeConfig(fixture.config, fixture.configPath)
|
|
257
844
|
const owner = new RollbridgeDaemon({config: initialConfig, configPath: fixture.configPath, logger: () => {}})
|
|
258
845
|
/** @type {RollbridgeDaemon | undefined} */
|
|
@@ -281,6 +868,12 @@ test("owner recovery preserves exact release definitions across a candidate conf
|
|
|
281
868
|
|
|
282
869
|
await owner.retireCommittedOwner(undefined)
|
|
283
870
|
owner.guardian?.disconnect()
|
|
871
|
+
const partialPublicState = JSON.parse(await fs.readFile(fixture.statePath, "utf8"))
|
|
872
|
+
|
|
873
|
+
partialPublicState.generationTransition.phase = "committed"
|
|
874
|
+
partialPublicState.releases = partialPublicState.releases.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
|
|
875
|
+
partialPublicState.releaseReferences = partialPublicState.releaseReferences.filter((/** @type {{releaseId: string}} */ release) => release.releaseId !== "v2")
|
|
876
|
+
await fs.writeFile(fixture.statePath, `${JSON.stringify(partialPublicState, null, 2)}\n`)
|
|
284
877
|
|
|
285
878
|
recovered = new RollbridgeDaemon({config: normalizeConfig(changedConfig, fixture.configPath), configPath: fixture.configPath, logger: () => {}})
|
|
286
879
|
await recovered.start()
|
|
@@ -289,12 +882,7 @@ test("owner recovery preserves exact release definitions across a candidate conf
|
|
|
289
882
|
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
290
883
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
291
884
|
} 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
|
-
}
|
|
885
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
298
886
|
await stopFixtureGuardian(fixture.statePath)
|
|
299
887
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
300
888
|
}
|
|
@@ -330,6 +918,7 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
|
|
|
330
918
|
|
|
331
919
|
assert.equal(pending.activeReleaseId, "v2")
|
|
332
920
|
assert.equal(pending.generationTransition?.phase, "committed_pending")
|
|
921
|
+
assert.equal(pending.serviceReleaseIds?.beacon, "v2")
|
|
333
922
|
assert.equal(pending.singletonReleaseIds?.singleton, "v1")
|
|
334
923
|
const stoppedRelease = pending.releases.find((release) => release.releaseId === "v1" && release.state === "stopped")
|
|
335
924
|
|
|
@@ -369,6 +958,9 @@ test("owner recovery reconstructs a stopped release that still owns a committed-
|
|
|
369
958
|
|
|
370
959
|
test("owner recovery uses the owning release singleton definition during a committed-pending config change", async () => {
|
|
371
960
|
const fixture = await createFixture()
|
|
961
|
+
const fixtureProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
962
|
+
|
|
963
|
+
fixture.config.processes = fixtureProcesses.filter((processConfig) => processConfig.id !== "beacon")
|
|
372
964
|
const initialProcesses = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
373
965
|
const initialSingleton = initialProcesses.find((processConfig) => processConfig.id === "singleton")
|
|
374
966
|
|
|
@@ -435,6 +1027,67 @@ test("owner recovery uses the owning release singleton definition during a commi
|
|
|
435
1027
|
}
|
|
436
1028
|
})
|
|
437
1029
|
|
|
1030
|
+
test("owner recovery retains a stopped previous release until committed-pending singleton work completes", async () => {
|
|
1031
|
+
const fixture = await createFixture()
|
|
1032
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
1033
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
1034
|
+
|
|
1035
|
+
assert.ok(worker)
|
|
1036
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
1037
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1038
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
1039
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1040
|
+
const replaceSingletons = owner.replaceSingletons.bind(owner)
|
|
1041
|
+
/** @type {() => void} */
|
|
1042
|
+
let continueReplacement = () => {}
|
|
1043
|
+
/** @type {() => void} */
|
|
1044
|
+
let markReplacementComplete = () => {}
|
|
1045
|
+
const replacementGate = new Promise((resolve) => { continueReplacement = () => resolve(undefined) })
|
|
1046
|
+
const replacementComplete = new Promise((resolve) => { markReplacementComplete = () => resolve(undefined) })
|
|
1047
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1048
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
1049
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
1050
|
+
let deployPromise
|
|
1051
|
+
/** @type {RollbridgeDaemon | undefined} */
|
|
1052
|
+
let recovered
|
|
1053
|
+
|
|
1054
|
+
owner.replaceSingletons = async (release) => {
|
|
1055
|
+
await replaceSingletons(release)
|
|
1056
|
+
if (release.releaseId === "v2") {
|
|
1057
|
+
markReplacementComplete()
|
|
1058
|
+
await replacementGate
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
await owner.start()
|
|
1064
|
+
await owner.deploy({releaseId: "v1", releasePath: v1Path, revision: "v1"})
|
|
1065
|
+
deployPromise = owner.deploy({releaseId: "v2", releasePath: v2Path, revision: "v2"})
|
|
1066
|
+
void deployPromise.catch(() => {})
|
|
1067
|
+
await replacementComplete
|
|
1068
|
+
const pending = await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed_pending" && state.releases?.some((release) => release.releaseId === "v1" && release.state === "stopped"), AbortSignal.timeout(5000))
|
|
1069
|
+
|
|
1070
|
+
assert.deepEqual(pending.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2"])
|
|
1071
|
+
assert.equal(pending.singletonReleaseIds?.singleton, "v2")
|
|
1072
|
+
await owner.retireCommittedOwner(undefined)
|
|
1073
|
+
owner.guardian?.disconnect()
|
|
1074
|
+
|
|
1075
|
+
recovered = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1076
|
+
await recovered.start()
|
|
1077
|
+
|
|
1078
|
+
assert.equal(recovered.status().generationTransition?.phase, "committed")
|
|
1079
|
+
assert.equal(recovered.status().activeReleaseId, "v2")
|
|
1080
|
+
} finally {
|
|
1081
|
+
continueReplacement()
|
|
1082
|
+
await deployPromise?.catch(() => {})
|
|
1083
|
+
if (recovered) await recovered.shutdown().catch(() => {})
|
|
1084
|
+
else await owner.shutdown().catch(() => {})
|
|
1085
|
+
owner.guardian?.disconnect()
|
|
1086
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1087
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1088
|
+
}
|
|
1089
|
+
})
|
|
1090
|
+
|
|
438
1091
|
test("owner recovery replays ambiguous retirement with the previous release's exact definition", async () => {
|
|
439
1092
|
const fixture = await createFixture()
|
|
440
1093
|
const retirementGatePath = path.join(fixture.root, "retirement.allow")
|
|
@@ -469,8 +1122,11 @@ test("owner recovery replays ambiguous retirement with the previous release's ex
|
|
|
469
1122
|
await fs.writeFile(retirementGatePath, "release retirement\n")
|
|
470
1123
|
|
|
471
1124
|
owner = spawnDaemon(fixture.configPath)
|
|
1125
|
+
const recoverySettled = waitForLog(owner, "release generation transition recovery settled")
|
|
472
1126
|
|
|
473
1127
|
await waitForLog(owner, "control socket listening")
|
|
1128
|
+
await waitForState(fixture.statePath, (state) => state.generationTransition?.phase === "committed", AbortSignal.timeout(5000))
|
|
1129
|
+
await recoverySettled
|
|
474
1130
|
assert.equal((await fs.readFile(retirementWaitingPath, "utf8")).trim().split("\n").length, 2, "ambiguous retirement must replay exactly once")
|
|
475
1131
|
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v1", "activate:v2"])
|
|
476
1132
|
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2")
|
|
@@ -507,7 +1163,7 @@ test("owner recovery rejects config identity mismatch without changing the valid
|
|
|
507
1163
|
const rejected = await runDaemon(fixture.configPath)
|
|
508
1164
|
|
|
509
1165
|
assert.notEqual(rejected.code, 0)
|
|
510
|
-
assert.match(rejected.output, /
|
|
1166
|
+
assert.match(rejected.output, /authority does not match/)
|
|
511
1167
|
assert.equal(await fs.readFile(fixture.statePath, "utf8"), validState)
|
|
512
1168
|
|
|
513
1169
|
await writeConfig(fixture.configPath, fixture.config)
|
|
@@ -566,9 +1222,10 @@ test("failed recovery bootstrap keeps the reconstructed active generation servin
|
|
|
566
1222
|
}
|
|
567
1223
|
})
|
|
568
1224
|
|
|
569
|
-
test("owner recovery
|
|
1225
|
+
test("owner recovery repairs a partial public snapshot from committed guardian state", async () => {
|
|
570
1226
|
const fixture = await createFixture()
|
|
571
|
-
|
|
1227
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
1228
|
+
let recoveredDaemonPid
|
|
572
1229
|
let workerPid
|
|
573
1230
|
|
|
574
1231
|
try {
|
|
@@ -585,21 +1242,24 @@ test("owner recovery fails closed on a partial snapshot and preserves it for rep
|
|
|
585
1242
|
const partialState = {...validState, releases: []}
|
|
586
1243
|
await fs.writeFile(fixture.statePath, `${JSON.stringify(partialState, null, 2)}\n`)
|
|
587
1244
|
|
|
588
|
-
const
|
|
1245
|
+
const repairedState = await waitForState(
|
|
1246
|
+
fixture.statePath,
|
|
1247
|
+
(state) => state.daemonPid !== validState.daemonPid && state.releases.some((release) => release.releaseId === "v1"),
|
|
1248
|
+
AbortSignal.timeout(5000)
|
|
1249
|
+
)
|
|
1250
|
+
recoveredDaemonPid = repairedState.daemonPid
|
|
1251
|
+
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
589
1252
|
|
|
590
|
-
assert.
|
|
591
|
-
assert.
|
|
592
|
-
assert.
|
|
1253
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
1254
|
+
assert.equal(releaseProcessPid(recovered, "v1", "worker"), workerPid)
|
|
1255
|
+
assert.notDeepEqual(JSON.parse(await fs.readFile(fixture.statePath, "utf8")), partialState)
|
|
593
1256
|
|
|
594
|
-
await fs.writeFile(fixture.statePath, `${JSON.stringify(validState, null, 2)}\n`)
|
|
595
|
-
owner = spawnDaemon(fixture.configPath)
|
|
596
|
-
await waitForLog(owner, "control socket listening")
|
|
597
1257
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
598
1258
|
await fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")
|
|
599
1259
|
await shutdown
|
|
600
|
-
await once(owner, "exit")
|
|
601
1260
|
} finally {
|
|
602
1261
|
await killChild(owner)
|
|
1262
|
+
if (recoveredDaemonPid && isProcessRunning(recoveredDaemonPid)) process.kill(recoveredDaemonPid, "SIGKILL")
|
|
603
1263
|
if (workerPid) {
|
|
604
1264
|
try { process.kill(-workerPid, "SIGKILL") } catch (_error) { /* The exact group already exited. */ }
|
|
605
1265
|
}
|
|
@@ -761,6 +1421,75 @@ test("deploy rejects a live ownerRecovery mode change", async () => {
|
|
|
761
1421
|
}
|
|
762
1422
|
})
|
|
763
1423
|
|
|
1424
|
+
test("deploy rejects a live activation lifecycle mode change", async () => {
|
|
1425
|
+
const fixture = await createFixture()
|
|
1426
|
+
const owner = spawnDaemon(fixture.configPath)
|
|
1427
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1428
|
+
const v2Path = await prepareRelease(fixture.root, "v2")
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
await waitForLog(owner, "control socket listening")
|
|
1432
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
1433
|
+
const changedConfig = structuredClone(fixture.config)
|
|
1434
|
+
const jobs = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (changedConfig.processes).find((processConfig) => processConfig.id === "jobs")
|
|
1435
|
+
const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (jobs?.lifecycle)
|
|
1436
|
+
|
|
1437
|
+
delete lifecycle.activateCommand
|
|
1438
|
+
await writeConfig(fixture.configPath, changedConfig)
|
|
1439
|
+
await assert.rejects(
|
|
1440
|
+
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
1441
|
+
/lifecycle\.activateCommand.*cannot be applied live/
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1445
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
1446
|
+
|
|
1447
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1448
|
+
await shutdown
|
|
1449
|
+
} finally {
|
|
1450
|
+
await killChild(owner)
|
|
1451
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1452
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1453
|
+
}
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
test("public state does not advance when private guardian publication fails", async () => {
|
|
1457
|
+
const fixture = await createFixture()
|
|
1458
|
+
const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (fixture.config.processes)
|
|
1459
|
+
const worker = processes.find((processConfig) => processConfig.id === "worker")
|
|
1460
|
+
|
|
1461
|
+
assert.ok(worker)
|
|
1462
|
+
worker.lifecycle = {drainCommand: "true", drainTimeoutMs: 1000}
|
|
1463
|
+
await writeConfig(fixture.configPath, fixture.config)
|
|
1464
|
+
const config = normalizeConfig(fixture.config, fixture.configPath)
|
|
1465
|
+
const owner = new RollbridgeDaemon({config, configPath: fixture.configPath, logger: () => {}})
|
|
1466
|
+
const releasePath = await prepareRelease(fixture.root, "v1")
|
|
1467
|
+
|
|
1468
|
+
try {
|
|
1469
|
+
await owner.start()
|
|
1470
|
+
await owner.deploy({releaseId: "v1", releasePath, revision: "v1"})
|
|
1471
|
+
if (owner.pendingWrite) await owner.pendingWrite
|
|
1472
|
+
const before = await fs.readFile(fixture.statePath, "utf8")
|
|
1473
|
+
const publishOwnerState = owner.publishOwnerState.bind(owner)
|
|
1474
|
+
|
|
1475
|
+
owner.ownerReady = true
|
|
1476
|
+
owner.publishOwnerState = async () => { throw new Error("injected guardian publication failure") }
|
|
1477
|
+
const write = owner.persistState({throwOnError: true})
|
|
1478
|
+
|
|
1479
|
+
assert.ok(write)
|
|
1480
|
+
await assert.rejects(write, /injected guardian publication failure/)
|
|
1481
|
+
assert.equal(await fs.readFile(fixture.statePath, "utf8"), before)
|
|
1482
|
+
owner.publishOwnerState = publishOwnerState
|
|
1483
|
+
await owner.persistState({throwOnError: true})
|
|
1484
|
+
await owner.shutdown()
|
|
1485
|
+
} finally {
|
|
1486
|
+
owner.publishOwnerState = RollbridgeDaemon.prototype.publishOwnerState.bind(owner)
|
|
1487
|
+
await owner.shutdown().catch(() => {})
|
|
1488
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1489
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1490
|
+
}
|
|
1491
|
+
})
|
|
1492
|
+
|
|
764
1493
|
test("replacement removes only guardian-owned candidate inventory left before deploy commit", async () => {
|
|
765
1494
|
const fixture = await createFixture()
|
|
766
1495
|
let owner = spawnDaemon(fixture.configPath)
|
|
@@ -803,7 +1532,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
803
1532
|
|
|
804
1533
|
assert.equal(recovered.activeReleaseId, "v1")
|
|
805
1534
|
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath: v1Path}])
|
|
806
|
-
assert.equal(
|
|
1535
|
+
assert.equal(isProcessRunning(candidatePid), false, "uncommitted candidate must be stopped before replacement becomes healthy")
|
|
807
1536
|
|
|
808
1537
|
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
809
1538
|
assert.equal((await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})).activeReleaseId, "v2", "removed candidate keys must be reusable by a later valid deploy")
|
|
@@ -814,7 +1543,7 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
814
1543
|
await once(owner, "exit")
|
|
815
1544
|
} finally {
|
|
816
1545
|
await killChild(owner)
|
|
817
|
-
if (candidatePid &&
|
|
1546
|
+
if (candidatePid && isProcessRunning(candidatePid)) {
|
|
818
1547
|
try { process.kill(-candidatePid, "SIGKILL") } catch (_error) { /* Exact candidate group already exited. */ }
|
|
819
1548
|
}
|
|
820
1549
|
await stopFixtureGuardian(fixture.statePath)
|
|
@@ -822,6 +1551,47 @@ test("replacement removes only guardian-owned candidate inventory left before de
|
|
|
822
1551
|
}
|
|
823
1552
|
})
|
|
824
1553
|
|
|
1554
|
+
test("ensure-daemon replaces a same-authority owner whose control socket disappeared", async () => {
|
|
1555
|
+
const fixture = await createFixture()
|
|
1556
|
+
const runtimePath = path.join(fixture.root, "runtime")
|
|
1557
|
+
const daemonLogPath = path.join(fixture.root, "same-authority-replacement.log")
|
|
1558
|
+
const daemonPidPath = path.join(fixture.root, "same-authority-replacement.pid")
|
|
1559
|
+
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
1560
|
+
const ensureArgs = [
|
|
1561
|
+
"ensure-daemon", "--config", fixture.configPath,
|
|
1562
|
+
"--daemon-log-path", daemonLogPath,
|
|
1563
|
+
"--daemon-pid-path", daemonPidPath,
|
|
1564
|
+
"--daemon-runtime-path", runtimePath,
|
|
1565
|
+
"--daemon-start-timeout-ms", "5000"
|
|
1566
|
+
]
|
|
1567
|
+
|
|
1568
|
+
try {
|
|
1569
|
+
const first = await runCli(ensureArgs)
|
|
1570
|
+
|
|
1571
|
+
assert.equal(first.code, 0, first.output)
|
|
1572
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
1573
|
+
const before = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
1574
|
+
|
|
1575
|
+
await fs.rm(fixture.socketPath, {force: true})
|
|
1576
|
+
const replacement = await runCli(ensureArgs)
|
|
1577
|
+
|
|
1578
|
+
assert.equal(replacement.code, 0, `${replacement.output}\n${await fs.readFile(daemonLogPath, "utf8")}`)
|
|
1579
|
+
const after = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
1580
|
+
|
|
1581
|
+
assert.equal(after.activeReleaseId, "v1")
|
|
1582
|
+
assert.notEqual(after.daemonPid, before.daemonPid)
|
|
1583
|
+
await waitForProcessExit(before.daemonPid)
|
|
1584
|
+
|
|
1585
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
1586
|
+
|
|
1587
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1588
|
+
await shutdown
|
|
1589
|
+
} finally {
|
|
1590
|
+
await stopFixtureGuardian(fixture.statePath)
|
|
1591
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
1592
|
+
}
|
|
1593
|
+
})
|
|
1594
|
+
|
|
825
1595
|
test("ensure-daemon atomically replaces an incompatible owner without losing retained generations", async () => {
|
|
826
1596
|
const fixture = await createFixture()
|
|
827
1597
|
const oldControlPath = fixture.socketPath
|
|
@@ -884,6 +1654,16 @@ test("ensure-daemon atomically replaces an incompatible owner without losing ret
|
|
|
884
1654
|
assert.equal(after.services[0]?.process.pid, before.services[0]?.process.pid)
|
|
885
1655
|
assert.equal(after.singletons[0]?.process.pid, before.singletons[0]?.process.pid)
|
|
886
1656
|
assert.equal(retainedConnectionClosed, false, "listener-owned WebSocket must remain supervised across replacement")
|
|
1657
|
+
assert.ok(after.daemonPid)
|
|
1658
|
+
await fs.writeFile(daemonLogPath, "")
|
|
1659
|
+
process.kill(after.daemonPid, "SIGKILL")
|
|
1660
|
+
const restartedState = await waitForState(fixture.statePath, (state) => state.daemonPid !== after.daemonPid, AbortSignal.timeout(5000))
|
|
1661
|
+
const restarted = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: newControlPath}))
|
|
1662
|
+
|
|
1663
|
+
assert.equal(restarted.daemonPid, restartedState.daemonPid)
|
|
1664
|
+
assert.equal(Number((await fs.readFile(daemonPidPath, "utf8")).trim()), restarted.daemonPid)
|
|
1665
|
+
assert.deepEqual(restarted.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)), before.releases.map((release) => release.processes.map((processStatus) => processStatus.pid)))
|
|
1666
|
+
assert.match(await fs.readFile(daemonLogPath, "utf8"), /owner state recovered/)
|
|
887
1667
|
|
|
888
1668
|
const v3Path = await prepareRelease(fixture.root, "v3")
|
|
889
1669
|
await sendControlCommand({command: {command: "deploy", releaseId: "v3", releasePath: v3Path, revision: "v3"}, path: newControlPath})
|
|
@@ -1127,10 +1907,11 @@ async function stopFixtureGuardian(statePath) {
|
|
|
1127
1907
|
/**
|
|
1128
1908
|
* @param {string} statePath - State path.
|
|
1129
1909
|
* @param {(state: RecoveryState) => boolean} predicate - Completion predicate.
|
|
1910
|
+
* @param {AbortSignal} [signal] - Optional deadline signal.
|
|
1130
1911
|
* @returns {Promise<RecoveryState>} Matching state.
|
|
1131
1912
|
*/
|
|
1132
|
-
async function waitForState(statePath, predicate) {
|
|
1133
|
-
const watcher = fs.watch(path.dirname(statePath))
|
|
1913
|
+
async function waitForState(statePath, predicate, signal) {
|
|
1914
|
+
const watcher = fs.watch(path.dirname(statePath), {signal})
|
|
1134
1915
|
|
|
1135
1916
|
try {
|
|
1136
1917
|
const initial = /** @type {RecoveryState} */ (JSON.parse(await fs.readFile(statePath, "utf8")))
|
|
@@ -1184,17 +1965,6 @@ async function waitForFile(filePath, timeoutMs) {
|
|
|
1184
1965
|
}
|
|
1185
1966
|
}
|
|
1186
1967
|
|
|
1187
|
-
/**
|
|
1188
|
-
* @param {number} pid - Exact fixture process.
|
|
1189
|
-
* @param {number} timeoutMs - Bounded exit wait.
|
|
1190
|
-
*/
|
|
1191
|
-
async function waitForProcessExit(pid, timeoutMs) {
|
|
1192
|
-
const deadline = Date.now() + timeoutMs
|
|
1193
|
-
|
|
1194
|
-
while (isAlive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
1195
|
-
assert.equal(isAlive(pid), false, `process ${pid} did not exit within ${timeoutMs}ms`)
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
1968
|
/**
|
|
1199
1969
|
* Opens a live WebSocket through the fixture proxy.
|
|
1200
1970
|
* @param {number} port - Proxy port.
|
|
@@ -1232,29 +2002,18 @@ function releaseProcessPid(status, releaseId, processId) {
|
|
|
1232
2002
|
return pid
|
|
1233
2003
|
}
|
|
1234
2004
|
|
|
1235
|
-
/**
|
|
1236
|
-
* @param {number} pid - Exact fixture pid.
|
|
1237
|
-
* @returns {boolean} Whether the exact fixture process is alive.
|
|
1238
|
-
*/
|
|
1239
|
-
function isAlive(pid) {
|
|
1240
|
-
try {
|
|
1241
|
-
process.kill(pid, 0)
|
|
1242
|
-
return true
|
|
1243
|
-
} catch (error) {
|
|
1244
|
-
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
|
|
1245
|
-
throw error
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
2005
|
/**
|
|
1250
2006
|
* @param {string} configPath - Config path.
|
|
1251
2007
|
* @param {{releaseId: string, releasePath: string, revision: string}} [bootstrap] - Optional bootstrap tuple.
|
|
2008
|
+
* @param {{daemonPidPath?: string, startupTimeoutMs?: number}} [options] - Recovery command options.
|
|
1252
2009
|
* @returns {import("node:child_process").ChildProcess} Daemon process.
|
|
1253
2010
|
*/
|
|
1254
|
-
function spawnDaemon(configPath, bootstrap) {
|
|
2011
|
+
function spawnDaemon(configPath, bootstrap, options = {}) {
|
|
1255
2012
|
const args = [binPath, "daemon", "--config", configPath]
|
|
1256
2013
|
|
|
1257
2014
|
if (bootstrap) args.push("--release-id", bootstrap.releaseId, "--release-path", bootstrap.releasePath, "--revision", bootstrap.revision)
|
|
2015
|
+
if (options.daemonPidPath) args.push("--guardian-daemon-pid-path", options.daemonPidPath)
|
|
2016
|
+
if (options.startupTimeoutMs) args.push("--guardian-daemon-start-timeout-ms", String(options.startupTimeoutMs))
|
|
1258
2017
|
return spawn(process.execPath, args, {stdio: ["ignore", "pipe", "pipe"]})
|
|
1259
2018
|
}
|
|
1260
2019
|
|
|
@@ -1301,14 +2060,16 @@ async function runCli(args) {
|
|
|
1301
2060
|
/**
|
|
1302
2061
|
* @param {import("node:child_process").ChildProcess} child - Daemon child.
|
|
1303
2062
|
* @param {string} message - Structured log message.
|
|
2063
|
+
* @param {{allowChildExit?: boolean}} [options] - Whether inherited descriptors may outlive the original child.
|
|
1304
2064
|
*/
|
|
1305
|
-
async function waitForLog(child, message) {
|
|
2065
|
+
async function waitForLog(child, message, {allowChildExit = false} = {}) {
|
|
1306
2066
|
assert.ok(child.stdout)
|
|
1307
2067
|
child.stdout.setEncoding("utf8")
|
|
1308
2068
|
|
|
1309
2069
|
await new Promise((resolve, reject) => {
|
|
1310
2070
|
let buffer = ""
|
|
1311
2071
|
let stderr = ""
|
|
2072
|
+
const timer = setTimeout(() => finish(new Error(`Timed out waiting for daemon log ${message}: ${stderr.trim()}`)), 5000)
|
|
1312
2073
|
const onErrorData = (/** @type {string} */ chunk) => { stderr += chunk }
|
|
1313
2074
|
const onExit = () => finish(new Error(`Daemon exited before logging ${message}: ${stderr.trim()}`))
|
|
1314
2075
|
/** @param {string} chunk - Output chunk. */
|
|
@@ -1326,6 +2087,7 @@ async function waitForLog(child, message) {
|
|
|
1326
2087
|
}
|
|
1327
2088
|
/** @param {Error} [error] - Failure. */
|
|
1328
2089
|
const finish = (error) => {
|
|
2090
|
+
clearTimeout(timer)
|
|
1329
2091
|
child.off("exit", onExit)
|
|
1330
2092
|
child.stdout?.off("data", onData)
|
|
1331
2093
|
child.stderr?.off("data", onErrorData)
|
|
@@ -1333,7 +2095,7 @@ async function waitForLog(child, message) {
|
|
|
1333
2095
|
else resolve(undefined)
|
|
1334
2096
|
}
|
|
1335
2097
|
|
|
1336
|
-
child.once("exit", onExit)
|
|
2098
|
+
if (!allowChildExit) child.once("exit", onExit)
|
|
1337
2099
|
child.stdout?.on("data", onData)
|
|
1338
2100
|
child.stderr?.setEncoding("utf8").on("data", onErrorData)
|
|
1339
2101
|
})
|