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
|
@@ -7,9 +7,10 @@ import {normalizeConfig} from "../src/config.js"
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
|
|
10
|
+
* @param {() => boolean} [shouldStart] - Whether process starts remain allowed.
|
|
10
11
|
* @returns {ReleaseGroup} A release group ready for buildProcess.
|
|
11
12
|
*/
|
|
12
|
-
function buildRelease(webProcess) {
|
|
13
|
+
function buildRelease(webProcess, shouldStart = () => true) {
|
|
13
14
|
const config = normalizeConfig({
|
|
14
15
|
application: "demo",
|
|
15
16
|
control: {path: "/tmp/rollbridge-release-group.sock"},
|
|
@@ -17,7 +18,7 @@ function buildRelease(webProcess) {
|
|
|
17
18
|
proxy: {host: "127.0.0.1", port: 0}
|
|
18
19
|
})
|
|
19
20
|
|
|
20
|
-
return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1"})
|
|
21
|
+
return new ReleaseGroup({config, logger: () => {}, releaseId: "v1", releasePath: "/tmp/rel", revision: "v1", shouldStart})
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
test("templates interpolate values from the daemon environment", () => {
|
|
@@ -78,3 +79,19 @@ test("a referenced daemon environment variable that is unset fails fast", () =>
|
|
|
78
79
|
/Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
|
|
79
80
|
)
|
|
80
81
|
})
|
|
82
|
+
|
|
83
|
+
test("committed generation restoration does not start after shutdown begins", async () => {
|
|
84
|
+
const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}}, () => false)
|
|
85
|
+
const process = release.buildProcess(release.config.processes[0])
|
|
86
|
+
let starts = 0
|
|
87
|
+
|
|
88
|
+
release.state = "draining"
|
|
89
|
+
process.start = async () => {
|
|
90
|
+
starts += 1
|
|
91
|
+
throw new Error("process started after shutdown")
|
|
92
|
+
}
|
|
93
|
+
release.processes.set("web", process)
|
|
94
|
+
|
|
95
|
+
await assert.rejects(() => release.restartCommittedGeneration(), /shutting down/)
|
|
96
|
+
assert.equal(starts, 0)
|
|
97
|
+
})
|
|
@@ -10,6 +10,7 @@ import path from "node:path"
|
|
|
10
10
|
import test from "node:test"
|
|
11
11
|
import {fileURLToPath} from "node:url"
|
|
12
12
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
+
import GuardianClient from "../src/guardian-client.js"
|
|
13
14
|
|
|
14
15
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
15
16
|
const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
|
|
@@ -23,12 +24,14 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
23
24
|
const logPath = path.join(root, "daemon.log")
|
|
24
25
|
const pidPath = path.join(root, "daemon.pid")
|
|
25
26
|
const runtimePath = path.join(root, "runtime")
|
|
27
|
+
const statePath = path.join(root, "state.json")
|
|
26
28
|
|
|
27
29
|
try {
|
|
28
30
|
await Promise.all([prepareRelease(releaseA, true), prepareRelease(releaseB, true)])
|
|
29
31
|
await fs.writeFile(configPath, `export default ${JSON.stringify({
|
|
30
32
|
application: "runtime-retention-test",
|
|
31
33
|
control: {path: socketPath},
|
|
34
|
+
ownerRecovery: {reconnectGraceMs: 50},
|
|
32
35
|
processes: [{
|
|
33
36
|
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
34
37
|
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
@@ -36,7 +39,8 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
36
39
|
policy: "proxied",
|
|
37
40
|
port: {from: 0, to: 0}
|
|
38
41
|
}],
|
|
39
|
-
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
|
|
42
|
+
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0},
|
|
43
|
+
statePath
|
|
40
44
|
}, null, 2)}\n`)
|
|
41
45
|
|
|
42
46
|
await runReleaseCli(releaseA, [
|
|
@@ -52,7 +56,7 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
52
56
|
"--daemon-runtime-path", runtimePath
|
|
53
57
|
])
|
|
54
58
|
|
|
55
|
-
await fs.rm(
|
|
59
|
+
await fs.rm(releaseA, {recursive: true})
|
|
56
60
|
|
|
57
61
|
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
58
62
|
const proxyPort = /** @type {{port: number}} */ (status.proxy).port
|
|
@@ -66,14 +70,67 @@ test("detached daemon survives deletion of the release-local Rollbridge installa
|
|
|
66
70
|
assert.ok(!runtime.path.startsWith(releaseA), `runtime must be outside release A: ${runtime.path}`)
|
|
67
71
|
assert.equal(response.status, 200)
|
|
68
72
|
assert.equal(await response.text(), "deferred runtime loaded\n")
|
|
73
|
+
assert.equal(typeof status.daemonPid, "number")
|
|
74
|
+
const daemonPid = /** @type {number} */ (status.daemonPid)
|
|
75
|
+
|
|
76
|
+
process.kill(daemonPid, "SIGKILL")
|
|
77
|
+
const recoveredPid = await waitForChangedPid(pidPath, daemonPid)
|
|
78
|
+
const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
79
|
+
|
|
80
|
+
assert.equal(recovered.daemonPid, recoveredPid)
|
|
81
|
+
assert.equal(recovered.activeReleaseId, "B")
|
|
69
82
|
} finally {
|
|
70
83
|
try {
|
|
71
84
|
await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
72
85
|
} catch {
|
|
73
86
|
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
74
|
-
if (Number.isInteger(pid))
|
|
87
|
+
if (Number.isInteger(pid)) killProcessIfAlive(pid)
|
|
75
88
|
}
|
|
89
|
+
await stopGuardian(statePath)
|
|
90
|
+
|
|
91
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("ensure-daemon resolves an explicit relative config before changing to its durable directory", async () => {
|
|
96
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-relative-config-"))
|
|
97
|
+
const release = path.join(root, "release")
|
|
98
|
+
const configDirectory = path.join(release, "configs")
|
|
99
|
+
const configPath = path.join(configDirectory, "rollbridge.js")
|
|
100
|
+
const pidPath = path.join(root, "daemon.pid")
|
|
101
|
+
const socketPath = path.join(root, "control.sock")
|
|
102
|
+
const statePath = path.join(root, "state.json")
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await prepareRelease(release, false)
|
|
106
|
+
await fs.mkdir(configDirectory)
|
|
107
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify({
|
|
108
|
+
...basicConfig(path.relative(configDirectory, socketPath)),
|
|
109
|
+
ownerRecovery: {reconnectGraceMs: 50},
|
|
110
|
+
processes: [{
|
|
111
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
112
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
113
|
+
id: "web",
|
|
114
|
+
policy: "proxied",
|
|
115
|
+
port: {from: 0, to: 0}
|
|
116
|
+
}],
|
|
117
|
+
statePath: path.relative(configDirectory, statePath)
|
|
118
|
+
}, null, 2)}\n`)
|
|
119
|
+
await runReleaseCli(release, [
|
|
120
|
+
"deploy", "--ensure-daemon", "--config", path.relative(release, configPath),
|
|
121
|
+
"--release-path", release, "--release-id", "relative-config",
|
|
122
|
+
"--daemon-log-path", path.join(root, "daemon.log"), "--daemon-pid-path", pidPath,
|
|
123
|
+
"--daemon-runtime-path", path.join(root, "runtime"), "--daemon-start-timeout-ms", "1000"
|
|
124
|
+
])
|
|
125
|
+
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
126
|
+
|
|
127
|
+
assert.equal(status.activeReleaseId, "relative-config")
|
|
128
|
+
} finally {
|
|
129
|
+
await sendControlCommand({command: {command: "shutdown"}, path: socketPath}).catch(() => undefined)
|
|
130
|
+
await stopGuardian(statePath)
|
|
131
|
+
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
76
132
|
|
|
133
|
+
if (Number.isInteger(pid)) killProcessIfAlive(pid)
|
|
77
134
|
await fs.rm(root, {force: true, recursive: true})
|
|
78
135
|
}
|
|
79
136
|
})
|
|
@@ -349,7 +406,7 @@ function basicConfig(socketPath) {
|
|
|
349
406
|
*/
|
|
350
407
|
async function runReleaseCli(releasePath, args) {
|
|
351
408
|
const binPath = path.join(releasePath, "node_modules", "rollbridge", "bin", "rollbridge")
|
|
352
|
-
const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
409
|
+
const child = spawn(process.execPath, [binPath, ...args], {cwd: releasePath, stdio: ["ignore", "pipe", "pipe"]})
|
|
353
410
|
let output = ""
|
|
354
411
|
|
|
355
412
|
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
@@ -358,3 +415,63 @@ async function runReleaseCli(releasePath, args) {
|
|
|
358
415
|
|
|
359
416
|
if (code !== 0) throw new Error(output)
|
|
360
417
|
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} pidPath - Daemon PID file.
|
|
421
|
+
* @param {number} previousPid - Exited daemon PID.
|
|
422
|
+
* @returns {Promise<number>} Recovered daemon PID.
|
|
423
|
+
*/
|
|
424
|
+
async function waitForChangedPid(pidPath, previousPid) {
|
|
425
|
+
const deadline = Date.now() + 5000
|
|
426
|
+
|
|
427
|
+
while (Date.now() < deadline) {
|
|
428
|
+
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
429
|
+
|
|
430
|
+
if (Number.isInteger(pid) && pid !== previousPid) return pid
|
|
431
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
432
|
+
}
|
|
433
|
+
throw new Error(`Timed out waiting for ${pidPath} to publish a recovered daemon PID`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** @param {number} pid - Exact fixture process PID. */
|
|
437
|
+
function killProcessIfAlive(pid) {
|
|
438
|
+
try {
|
|
439
|
+
process.kill(pid, "SIGKILL")
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** @param {string} statePath - Owner-recovery state path. */
|
|
446
|
+
async function stopGuardian(statePath) {
|
|
447
|
+
try {
|
|
448
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
449
|
+
const identity = state.recovery?.guardian
|
|
450
|
+
const pid = identity?.pid
|
|
451
|
+
|
|
452
|
+
if (identity?.socketPath && identity.token) {
|
|
453
|
+
const client = new GuardianClient(identity)
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
await client.connect()
|
|
457
|
+
for (const entry of await client.inventory()) {
|
|
458
|
+
if (entry.status.pid) {
|
|
459
|
+
try { process.kill(-entry.status.pid, "SIGKILL") } catch (error) {
|
|
460
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
client.disconnect()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (typeof pid === "number") {
|
|
469
|
+
const command = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8")).replaceAll("\0", " ")
|
|
470
|
+
|
|
471
|
+
if (!command.includes("process-guardian.js") || !command.includes(statePath)) throw new Error(`Refusing to stop unverified fixture guardian pid ${pid}`)
|
|
472
|
+
process.kill(pid, "SIGKILL")
|
|
473
|
+
}
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ESRCH"].includes(String(error.code))) throw error
|
|
476
|
+
}
|
|
477
|
+
}
|
package/test/rollbridge.test.js
CHANGED
|
@@ -525,6 +525,27 @@ test("opt-in generation lifecycle retires the old generation before activating a
|
|
|
525
525
|
}
|
|
526
526
|
})
|
|
527
527
|
|
|
528
|
+
test("manual restart reaches the active handoff coordinator and restores its lifecycle role", async () => {
|
|
529
|
+
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, webDependsOnService: true})
|
|
530
|
+
const daemon = await startDaemon(fixture.config)
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
534
|
+
const before = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
|
|
535
|
+
const result = await daemon.restartProcesses({processId: "beacon"})
|
|
536
|
+
const after = statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.pid
|
|
537
|
+
|
|
538
|
+
assert.deepEqual(result, {restarted: ["beacon"]})
|
|
539
|
+
assert.ok(before)
|
|
540
|
+
assert.ok(after)
|
|
541
|
+
assert.notEqual(after, before)
|
|
542
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v1"])
|
|
543
|
+
} finally {
|
|
544
|
+
await daemon.shutdown()
|
|
545
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
|
|
528
549
|
test("generation commit is durable before awaited post-transition work", async () => {
|
|
529
550
|
const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, includeSingleton: true, webDependsOnService: true})
|
|
530
551
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports whether an exact fixture process can still run. Linux keeps exited children in
|
|
7
|
+
* procfs until they are reaped, so kill(2) alone cannot distinguish a zombie from a live process.
|
|
8
|
+
* @param {number} pid - Exact fixture process PID.
|
|
9
|
+
* @returns {boolean} Whether the process can still run.
|
|
10
|
+
*/
|
|
11
|
+
export function isProcessRunning(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (process.platform !== "linux") return true
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8")
|
|
23
|
+
const state = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0]
|
|
24
|
+
|
|
25
|
+
return state !== "Z" && state !== "X"
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {number} pid - Exact fixture process PID.
|
|
34
|
+
* @param {number} [timeoutMs] - Bounded exit wait.
|
|
35
|
+
*/
|
|
36
|
+
export async function waitForProcessExit(pid, timeoutMs = 5000) {
|
|
37
|
+
const deadline = Date.now() + timeoutMs
|
|
38
|
+
|
|
39
|
+
while (isProcessRunning(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
40
|
+
if (isProcessRunning(pid)) throw new Error(`Timed out waiting for process ${pid} to exit`)
|
|
41
|
+
}
|