rollbridge 0.1.49 → 0.1.55
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 +5 -0
- package/README.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/docs/cli.md +7 -1
- package/docs/generation-deployment-contract.md +9 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/cli.js +10 -2
- package/src/daemon.js +102 -12
- package/src/process-guardian.js +5 -1
- package/src/release-group.js +48 -1
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +75 -14
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +160 -145
- package/test/health.test.js +6 -4
- package/test/logs.test.js +23 -17
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +254 -239
- package/test/owner-replacement.test.js +228 -223
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +16 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +388 -395
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
|
@@ -1,68 +1,77 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import {execFile} from "node:child_process"
|
|
5
4
|
import fs from "node:fs/promises"
|
|
6
5
|
import os from "node:os"
|
|
7
6
|
import path from "node:path"
|
|
8
|
-
import test from "
|
|
7
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
9
8
|
import {fileURLToPath} from "node:url"
|
|
10
9
|
import {promisify} from "node:util"
|
|
11
10
|
|
|
11
|
+
describe("package-metadata", () => {
|
|
12
|
+
|
|
12
13
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
13
14
|
const execFileAsync = promisify(execFile)
|
|
14
15
|
|
|
15
16
|
test("package.json declares publish metadata", async () => {
|
|
16
17
|
const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
expect(pkg.name).toBe("rollbridge")
|
|
20
|
+
expect(pkg.license).toBe("MIT")
|
|
21
|
+
expect(pkg.homepage).toBe("https://github.com/kaspernj/rollbridge#readme")
|
|
22
|
+
expect(pkg.bugs.url).toBe("https://github.com/kaspernj/rollbridge/issues")
|
|
23
|
+
expect(pkg.repository.type).toBe("git")
|
|
24
|
+
expect(pkg.repository.url).toMatch(/github\.com\/kaspernj\/rollbridge/)
|
|
25
|
+
expect(typeof pkg.author === "string" && pkg.author.length > 0).toBeTruthy()
|
|
26
|
+
expect(Array.isArray(pkg.keywords) && pkg.keywords.length > 0).toBeTruthy()
|
|
26
27
|
})
|
|
27
28
|
|
|
28
29
|
test("a LICENSE file matching the declared license exists", async () => {
|
|
29
30
|
const license = await fs.readFile(path.join(repoRoot, "LICENSE"), "utf8")
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
expect(license).toMatch(/MIT License/)
|
|
33
|
+
expect(license).toMatch(/Copyright \(c\) \d{4} kaspernj/)
|
|
33
34
|
})
|
|
34
35
|
|
|
35
|
-
test("package manifest excludes unexpected operational and coverage files", async (
|
|
36
|
+
test("package manifest excludes unexpected operational and coverage files", async () => {
|
|
36
37
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-pack-"))
|
|
37
|
-
t.after(() => fs.rm(fixtureRoot, {force: true, recursive: true}))
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
try {
|
|
40
|
+
await fs.cp(repoRoot, fixtureRoot, {
|
|
41
|
+
filter: (source) => {
|
|
42
|
+
const relative = path.relative(repoRoot, source)
|
|
43
|
+
return relative !== ".git" && relative !== "node_modules" && relative !== "tmp"
|
|
44
|
+
},
|
|
45
|
+
recursive: true,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const unexpectedTmpPath = path.join(fixtureRoot, "tmp", "worker-control", "unexpected-transcript.jsonl")
|
|
49
|
+
const unexpectedCoveragePath = path.join(fixtureRoot, "coverage", "unexpected.txt")
|
|
50
|
+
await Promise.all([
|
|
51
|
+
fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
|
|
52
|
+
fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
|
|
53
|
+
])
|
|
54
|
+
await Promise.all([
|
|
55
|
+
fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
|
|
56
|
+
fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
|
|
57
|
+
])
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
|
|
51
|
-
fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
|
|
52
|
-
])
|
|
53
|
-
await Promise.all([
|
|
54
|
-
fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
|
|
55
|
-
fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
|
|
56
|
-
])
|
|
59
|
+
const {stdout} = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {cwd: fixtureRoot})
|
|
60
|
+
const packageOutput = JSON.parse(stdout)
|
|
61
|
+
const packageArchive = Array.isArray(packageOutput) ? packageOutput[0] : Object.values(packageOutput)[0]
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
if (!(packageArchive && Array.isArray(packageArchive.files))) throw new Error("Expected package archive file list")
|
|
64
|
+
/** @type {Array<{path: string}>} */
|
|
65
|
+
const packageFiles = packageArchive.files
|
|
66
|
+
const packagePaths = packageFiles.map((file) => file.path)
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
for (const requiredPath of ["LICENSE", "README.md", "bin/rollbridge", "package.json", "src/cli.js", "src/daemon-runtime.js"]) {
|
|
69
|
+
expect({value: Boolean(packagePaths.includes(requiredPath)), context: `expected package to include ${requiredPath}`}).toMatchObject({value: true})
|
|
70
|
+
}
|
|
71
|
+
expect(!packagePaths.some((packagePath) => packagePath === "tmp" || packagePath.startsWith("tmp/"))).toBeTruthy()
|
|
72
|
+
expect(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/"))).toBeTruthy()
|
|
73
|
+
} finally {
|
|
74
|
+
await fs.rm(fixtureRoot, {force: true, recursive: true})
|
|
65
75
|
}
|
|
66
|
-
|
|
67
|
-
assert.ok(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/")))
|
|
76
|
+
})
|
|
68
77
|
})
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import net from "node:net"
|
|
5
|
-
import test from "
|
|
4
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
6
5
|
import {findAvailablePort} from "../src/port-allocator.js"
|
|
7
6
|
|
|
7
|
+
describe("port-allocator", () => {
|
|
8
|
+
|
|
8
9
|
const host = "127.0.0.1"
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -40,18 +41,13 @@ test("findAvailablePort reports reserved and in-use counts when a range is exhau
|
|
|
40
41
|
const range = {from: reservedPort, to: port}
|
|
41
42
|
|
|
42
43
|
try {
|
|
43
|
-
|
|
44
|
-
() => findAvailablePort({host, range, usedPorts: new Set([reservedPort])}),
|
|
45
|
-
(error) => {
|
|
46
|
-
assert.ok(error instanceof Error)
|
|
47
|
-
assert.match(error.message, new RegExp(`No available ports in range ${reservedPort}-${port}`))
|
|
48
|
-
assert.match(error.message, /2 ports on 127\.0\.0\.1/)
|
|
49
|
-
assert.match(error.message, /1 reserved by this deploy/)
|
|
50
|
-
assert.match(error.message, /1 already in use/)
|
|
44
|
+
const allocation = findAvailablePort({host, range, usedPorts: new Set([reservedPort])})
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
46
|
+
await expect(allocation).rejects.toBeInstanceOf(Error)
|
|
47
|
+
await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(new RegExp(`No available ports in range ${reservedPort}-${port}`))})
|
|
48
|
+
await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(/2 ports on 127\.0\.0\.1/)})
|
|
49
|
+
await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(/1 reserved by this deploy/)})
|
|
50
|
+
await expect(allocation).rejects.toMatchObject({message: expect.stringMatching(/1 already in use/)})
|
|
55
51
|
} finally {
|
|
56
52
|
await closeServer(server)
|
|
57
53
|
}
|
|
@@ -67,10 +63,11 @@ test("findAvailablePort skips the occupied port and records the allocated one",
|
|
|
67
63
|
try {
|
|
68
64
|
const allocated = await findAvailablePort({host, range: {from, to}, usedPorts})
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
expect(allocated).not.toBe(port)
|
|
67
|
+
expect(allocated >= from && allocated <= to).toBeTruthy()
|
|
68
|
+
expect(usedPorts.has(allocated)).toBeTruthy()
|
|
73
69
|
} finally {
|
|
74
70
|
await closeServer(server)
|
|
75
71
|
}
|
|
76
72
|
})
|
|
73
|
+
})
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import {spawn} from "node:child_process"
|
|
5
4
|
import {once} from "node:events"
|
|
6
5
|
import fs from "node:fs/promises"
|
|
7
6
|
import os from "node:os"
|
|
8
7
|
import path from "node:path"
|
|
9
|
-
import test from "
|
|
8
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
10
9
|
import {normalizeConfig} from "../src/config.js"
|
|
11
10
|
import {isProcessAlive} from "../src/state-store.js"
|
|
12
11
|
import {predeployCleanup} from "../src/predeploy-cleanup.js"
|
|
13
12
|
|
|
13
|
+
describe("predeploy-cleanup", () => {
|
|
14
|
+
|
|
14
15
|
/**
|
|
15
16
|
* @param {string} dir - Working directory.
|
|
16
17
|
* @param {string} marker - Unique process marker.
|
|
@@ -39,11 +40,11 @@ test("predeploy cleanup stops configured legacy process when no daemon is active
|
|
|
39
40
|
try {
|
|
40
41
|
const result = await predeployCleanup({config: buildConfig(dir, marker)})
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
expect(result.action).toBe("no-daemon-cleaned")
|
|
44
|
+
expect(result.recoveredOrphans).toBe(0)
|
|
45
|
+
expect(result.legacyProcesses.length).toBe(1)
|
|
46
|
+
expect(result.legacyProcesses[0].pid).toBe(legacy.pid)
|
|
47
|
+
expect(legacy.pid === undefined || !isProcessAlive(legacy.pid)).toBeTruthy()
|
|
47
48
|
} finally {
|
|
48
49
|
legacy.kill("SIGKILL")
|
|
49
50
|
await fs.rm(dir, {force: true, recursive: true})
|
|
@@ -74,7 +75,7 @@ test("predeploy cleanup leaves legacy processes alone when daemon already has an
|
|
|
74
75
|
})
|
|
75
76
|
})
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
expect(result).toEqual({
|
|
78
79
|
action: "daemon-active",
|
|
79
80
|
legacyProcesses: [],
|
|
80
81
|
recoveredOrphans: 0
|
|
@@ -123,9 +124,10 @@ test("predeploy cleanup stops an active daemon when the proxy config changed", a
|
|
|
123
124
|
}
|
|
124
125
|
})
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
expect(result.action).toBe("daemon-stopped")
|
|
128
|
+
expect(commands.map((command) => command.command)).toEqual(["status", "shutdown"])
|
|
128
129
|
} finally {
|
|
129
130
|
await fs.rm(dir, {force: true, recursive: true})
|
|
130
131
|
}
|
|
131
132
|
})
|
|
133
|
+
})
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs"
|
|
5
|
-
import test from "
|
|
4
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
6
5
|
import os from "node:os"
|
|
7
6
|
import path from "node:path"
|
|
8
7
|
import {measureProcessGroupRssBytes, processGroupHasLiveMembers, processGroupMembers} from "../src/process-memory.js"
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
describe("process-memory", () => {
|
|
10
|
+
|
|
11
|
+
const linuxTest = process.platform === "linux" ? test : test.skip
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* @returns {number} The current process's group id, read from /proc.
|
|
@@ -18,27 +19,27 @@ function currentProcessGroupId() {
|
|
|
18
19
|
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2])
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
linuxTest("measures the resident memory of a live process group", () => {
|
|
22
23
|
const rssBytes = measureProcessGroupRssBytes(currentProcessGroupId())
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
expect({value: Boolean(typeof rssBytes === "number" && rssBytes > 0), context: `expected a positive RSS, got ${rssBytes}`}).toMatchObject({value: true})
|
|
25
26
|
})
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
linuxTest("returns undefined for a process group with no members", () => {
|
|
29
|
+
expect(measureProcessGroupRssBytes(2147483646)).toBe(undefined)
|
|
29
30
|
})
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
linuxTest("lists process-group members with their command and resident memory", () => {
|
|
32
33
|
const members = processGroupMembers(currentProcessGroupId())
|
|
33
34
|
const self = members.find((member) => member.pid === process.pid)
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
if (!self) throw new Error("the current process should be a group member")
|
|
37
|
+
expect(typeof self.rssBytes === "number" && self.rssBytes > 0).toBeTruthy()
|
|
38
|
+
expect(typeof self.command).toBe("string")
|
|
38
39
|
})
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
linuxTest("returns an empty list for a process group with no members", () => {
|
|
42
|
+
expect(processGroupMembers(2147483646)).toEqual([])
|
|
42
43
|
})
|
|
43
44
|
|
|
44
45
|
test("treats a process group containing only defunct members as stopped", () => {
|
|
@@ -48,13 +49,14 @@ test("treats a process group containing only defunct members as stopped", () =>
|
|
|
48
49
|
fs.mkdirSync(path.join(procPath, "101"))
|
|
49
50
|
fs.writeFileSync(path.join(procPath, "101", "stat"), "101 (worker) Z 1 77 0 0")
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
expect(processGroupHasLiveMembers(77, procPath)).toBe(false)
|
|
52
53
|
|
|
53
54
|
fs.mkdirSync(path.join(procPath, "102"))
|
|
54
55
|
fs.writeFileSync(path.join(procPath, "102", "stat"), "102 (worker) S 1 77 0 0")
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
expect(processGroupHasLiveMembers(77, procPath)).toBe(true)
|
|
57
58
|
} finally {
|
|
58
59
|
fs.rmSync(procPath, {force: true, recursive: true})
|
|
59
60
|
}
|
|
60
61
|
})
|
|
62
|
+
})
|
package/test/proxy.test.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {fileURLToPath} from "node:url"
|
|
9
8
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
10
9
|
import {normalizeConfig} from "../src/config.js"
|
|
11
10
|
|
|
11
|
+
describe("proxy", () => {
|
|
12
|
+
|
|
12
13
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
13
14
|
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
14
15
|
|
|
@@ -52,11 +53,11 @@ async function proxyFetch(daemon, pathName) {
|
|
|
52
53
|
function webPid(daemon, releaseId) {
|
|
53
54
|
const release = daemon.status().releases.find((candidate) => candidate.releaseId === releaseId)
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
if (!release) throw new Error(`Release ${releaseId} should be present`)
|
|
56
57
|
|
|
57
58
|
const web = release.processes.find((candidate) => candidate.id === "web")
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
if (!(web && typeof web.pid === "number")) throw new Error("web process should report a pid")
|
|
60
61
|
|
|
61
62
|
return web.pid
|
|
62
63
|
}
|
|
@@ -84,7 +85,7 @@ test("proxy returns 502 while the active release web process is down", async ()
|
|
|
84
85
|
|
|
85
86
|
try {
|
|
86
87
|
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
87
|
-
|
|
88
|
+
expect((await proxyFetch(daemon, "/release")).status).toBe(200)
|
|
88
89
|
|
|
89
90
|
// Kill the web process group so the active release exits unexpectedly; restart is held off for 60s.
|
|
90
91
|
process.kill(-webPid(daemon, "v1"), "SIGKILL")
|
|
@@ -97,7 +98,7 @@ test("proxy returns 502 while the active release web process is down", async ()
|
|
|
97
98
|
return lastStatus === 502
|
|
98
99
|
})
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
expect(lastStatus).toBe(502)
|
|
101
102
|
} finally {
|
|
102
103
|
await daemon.shutdown()
|
|
103
104
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -112,7 +113,7 @@ test("proxy recovers once the crashed web process restarts", async () => {
|
|
|
112
113
|
|
|
113
114
|
try {
|
|
114
115
|
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
115
|
-
|
|
116
|
+
expect((await proxyFetch(daemon, "/release")).status).toBe(200)
|
|
116
117
|
|
|
117
118
|
process.kill(-webPid(daemon, "v1"), "SIGKILL")
|
|
118
119
|
|
|
@@ -120,9 +121,10 @@ test("proxy recovers once the crashed web process restarts", async () => {
|
|
|
120
121
|
// observing the outage, then confirm the restart brings the proxy back to 200.
|
|
121
122
|
await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 502)
|
|
122
123
|
await waitFor(async () => (await proxyFetch(daemon, "/release")).status === 200)
|
|
123
|
-
|
|
124
|
+
expect((await proxyFetch(daemon, "/release")).status).toBe(200)
|
|
124
125
|
} finally {
|
|
125
126
|
await daemon.shutdown()
|
|
126
127
|
await fs.rm(root, {force: true, recursive: true})
|
|
127
128
|
}
|
|
128
129
|
})
|
|
130
|
+
})
|
package/test/recover.test.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import {spawn} from "node:child_process"
|
|
5
4
|
import {once} from "node:events"
|
|
6
5
|
import fs from "node:fs/promises"
|
|
7
6
|
import os from "node:os"
|
|
8
7
|
import path from "node:path"
|
|
9
|
-
import test from "
|
|
8
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
10
9
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
11
10
|
import {normalizeConfig} from "../src/config.js"
|
|
12
11
|
import {recoverOrphans} from "../src/recover.js"
|
|
13
12
|
import {isProcessAlive, readState, writeState} from "../src/state-store.js"
|
|
14
13
|
|
|
14
|
+
describe("recover", () => {
|
|
15
|
+
|
|
15
16
|
/**
|
|
16
17
|
* @param {string} dir - Working directory.
|
|
17
18
|
* @param {{statePath?: string}} [options] - Config options.
|
|
@@ -68,7 +69,7 @@ test("recover requires a configured statePath", async () => {
|
|
|
68
69
|
try {
|
|
69
70
|
const result = await recoverOrphans({config: buildConfig(dir), force: true})
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
expect("error" in result && /statePath/.test(result.error)).toBeTruthy()
|
|
72
73
|
} finally {
|
|
73
74
|
await fs.rm(dir, {force: true, recursive: true})
|
|
74
75
|
}
|
|
@@ -84,14 +85,16 @@ test("recover lists orphans without stopping them unless forced", async () => {
|
|
|
84
85
|
|
|
85
86
|
const result = await recoverOrphans({config: buildConfig(dir, {statePath}), force: false})
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
if ("error" in result) throw new Error(`Recovery failed: ${result.error}`)
|
|
89
|
+
expect(result.forced).toBe(false)
|
|
90
|
+
expect(result.cleared).toBe(false)
|
|
91
|
+
expect(result.remaining).toEqual([])
|
|
92
|
+
expect(result.orphans.length).toBe(1)
|
|
93
|
+
expect(result.orphans[0].pid).toBe(orphan.pid)
|
|
94
|
+
// The orphan must not be stopped by a dry run.
|
|
95
|
+
expect(orphan.pid !== undefined && isProcessAlive(orphan.pid)).toBeTruthy()
|
|
96
|
+
// A dry run must not clear the state file.
|
|
97
|
+
expect(await readState(statePath)).toBeTruthy()
|
|
95
98
|
} finally {
|
|
96
99
|
orphan.kill("SIGKILL")
|
|
97
100
|
await fs.rm(dir, {force: true, recursive: true})
|
|
@@ -108,12 +111,13 @@ test("recover --force stops orphan process groups and clears the state file", as
|
|
|
108
111
|
|
|
109
112
|
const result = await recoverOrphans({config: buildConfig(dir, {statePath}), force: true})
|
|
110
113
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
if ("error" in result) throw new Error(`Recovery failed: ${result.error}`)
|
|
115
|
+
expect(result.forced).toBe(true)
|
|
116
|
+
expect(result.cleared).toBe(true)
|
|
117
|
+
expect(result.remaining).toEqual([])
|
|
115
118
|
await waitFor(() => orphan.pid === undefined || !isProcessAlive(orphan.pid))
|
|
116
|
-
|
|
119
|
+
// The state file is cleared after a forced recovery.
|
|
120
|
+
expect(await readState(statePath)).toBe(undefined)
|
|
117
121
|
} finally {
|
|
118
122
|
orphan.kill("SIGKILL")
|
|
119
123
|
await fs.rm(dir, {force: true, recursive: true})
|
|
@@ -132,12 +136,14 @@ test("recover --force keeps the state file when an orphan cannot be stopped", as
|
|
|
132
136
|
// reports it is still alive.
|
|
133
137
|
const result = await recoverOrphans({config: buildConfig(dir, {statePath}), force: true, stopGroup: async () => false})
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
if ("error" in result) throw new Error(`Recovery failed: ${result.error}`)
|
|
140
|
+
expect(result.forced).toBe(true)
|
|
141
|
+
// The state file is kept when an orphan survives.
|
|
142
|
+
expect(result.cleared).toBe(false)
|
|
143
|
+
expect(result.remaining.length).toBe(1)
|
|
144
|
+
expect(result.remaining[0].pid).toBe(orphan.pid)
|
|
145
|
+
// The state file must remain so the operator can retry.
|
|
146
|
+
expect(await readState(statePath)).toBeTruthy()
|
|
141
147
|
} finally {
|
|
142
148
|
orphan.kill("SIGKILL")
|
|
143
149
|
await fs.rm(dir, {force: true, recursive: true})
|
|
@@ -154,9 +160,10 @@ test("recover refuses while a daemon is running", async () => {
|
|
|
154
160
|
try {
|
|
155
161
|
const result = await recoverOrphans({config, force: true})
|
|
156
162
|
|
|
157
|
-
|
|
163
|
+
expect("error" in result && /is using/.test(result.error)).toBeTruthy()
|
|
158
164
|
} finally {
|
|
159
165
|
await daemon.shutdown()
|
|
160
166
|
await fs.rm(dir, {force: true, recursive: true})
|
|
161
167
|
}
|
|
162
168
|
})
|
|
169
|
+
})
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import test from "node:test"
|
|
3
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
5
4
|
import ReleaseGroup from "../src/release-group.js"
|
|
6
5
|
import {normalizeConfig} from "../src/config.js"
|
|
7
6
|
|
|
7
|
+
describe("release-group", () => {
|
|
8
|
+
|
|
8
9
|
/**
|
|
9
10
|
* @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
|
|
10
11
|
* @param {() => boolean} [shouldStart] - Whether process starts remain allowed.
|
|
@@ -35,8 +36,8 @@ test("templates interpolate values from the daemon environment", () => {
|
|
|
35
36
|
try {
|
|
36
37
|
const managed = release.buildProcess(release.config.processes[0])
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
expect(managed.command).toBe("run --token from-daemon")
|
|
40
|
+
expect(managed.env.DOWNSTREAM_TOKEN).toBe("from-daemon")
|
|
40
41
|
} finally {
|
|
41
42
|
delete process.env.ROLLBRIDGE_ENV_TEST
|
|
42
43
|
}
|
|
@@ -56,15 +57,15 @@ test("replica processes get a replica index, count, and template context", () =>
|
|
|
56
57
|
const workerConfig = release.config.processes[1]
|
|
57
58
|
const replica = release.buildProcess(workerConfig, {count: 3, index: 1, instanceId: "worker#1"})
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
expect(replica.id).toBe("worker#1")
|
|
61
|
+
expect(replica.command).toBe("worker 1/3")
|
|
62
|
+
expect(replica.env.ROLLBRIDGE_REPLICA_INDEX).toBe("1")
|
|
63
|
+
expect(replica.env.ROLLBRIDGE_REPLICA_COUNT).toBe("3")
|
|
64
|
+
expect(replica.env.ROLLBRIDGE_PROCESS_ID).toBe("worker")
|
|
65
|
+
expect(replica.env.SLOT).toBe("1")
|
|
65
66
|
})
|
|
66
67
|
|
|
67
|
-
test("a referenced daemon environment variable that is unset fails fast", () => {
|
|
68
|
+
test("a referenced daemon environment variable that is unset fails fast", async () => {
|
|
68
69
|
const release = buildRelease({
|
|
69
70
|
command: "run {{env.ROLLBRIDGE_ENV_MISSING}}",
|
|
70
71
|
id: "web",
|
|
@@ -74,10 +75,7 @@ test("a referenced daemon environment variable that is unset fails fast", () =>
|
|
|
74
75
|
|
|
75
76
|
delete process.env.ROLLBRIDGE_ENV_MISSING
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
() => release.buildProcess(release.config.processes[0]),
|
|
79
|
-
/Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/
|
|
80
|
-
)
|
|
78
|
+
await expect(() => release.buildProcess(release.config.processes[0])).toThrow(/Missing template value for \{\{env.ROLLBRIDGE_ENV_MISSING\}\}/)
|
|
81
79
|
})
|
|
82
80
|
|
|
83
81
|
test("committed generation restoration does not start after shutdown begins", async () => {
|
|
@@ -92,6 +90,7 @@ test("committed generation restoration does not start after shutdown begins", as
|
|
|
92
90
|
}
|
|
93
91
|
release.processes.set("web", process)
|
|
94
92
|
|
|
95
|
-
await
|
|
96
|
-
|
|
93
|
+
await expect(release.restartCommittedGeneration()).rejects.toThrow(/shutting down/)
|
|
94
|
+
expect(starts).toBe(0)
|
|
95
|
+
})
|
|
97
96
|
})
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {fileURLToPath} from "node:url"
|
|
9
8
|
import RollbridgeDaemon, {releasesToPrune} from "../src/daemon.js"
|
|
10
9
|
import {normalizeConfig} from "../src/config.js"
|
|
11
10
|
|
|
11
|
+
describe("release-retention", () => {
|
|
12
|
+
|
|
12
13
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
13
14
|
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
14
15
|
|
|
@@ -23,7 +24,7 @@ test("releasesToPrune keeps the most recent stopped releases and never active or
|
|
|
23
24
|
|
|
24
25
|
const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
expect([...remove].sort()).toEqual(["v1", "v2"])
|
|
27
28
|
})
|
|
28
29
|
|
|
29
30
|
test("releasesToPrune keeps the later-deployed release when stoppedAt ties", () => {
|
|
@@ -36,7 +37,7 @@ test("releasesToPrune keeps the later-deployed release when stoppedAt ties", ()
|
|
|
36
37
|
|
|
37
38
|
const remove = releasesToPrune(releases, {keep: 1, maxAgeMs: 0}, Date.parse("2026-05-22T00:00:10.000Z"))
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
expect(remove).toEqual(["v1"])
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
test("releasesToPrune prunes stopped releases older than maxAgeMs", () => {
|
|
@@ -48,7 +49,7 @@ test("releasesToPrune prunes stopped releases older than maxAgeMs", () => {
|
|
|
48
49
|
|
|
49
50
|
const remove = releasesToPrune(releases, {keep: 100, maxAgeMs: 30000}, now)
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
expect(remove).toEqual(["old"])
|
|
52
53
|
})
|
|
53
54
|
|
|
54
55
|
test("the daemon prunes stopped releases beyond the retention count across deploys", async () => {
|
|
@@ -82,9 +83,9 @@ test("the daemon prunes stopped releases beyond the retention count across deplo
|
|
|
82
83
|
|
|
83
84
|
const ids = daemon.status().releases.map((release) => release.releaseId)
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
expect({value: Boolean(ids.includes("v3")), context: `active release should be retained, got ${JSON.stringify(ids)}`}).toMatchObject({value: true})
|
|
87
|
+
expect({value: Boolean(!ids.includes("v1")), context: `oldest stopped release should be pruned, got ${JSON.stringify(ids)}`}).toMatchObject({value: true})
|
|
88
|
+
expect({value: Boolean(ids.length <= 2), context: `expected at most the active release plus one stopped, got ${JSON.stringify(ids)}`}).toMatchObject({value: true})
|
|
88
89
|
} finally {
|
|
89
90
|
await daemon.shutdown()
|
|
90
91
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -105,3 +106,4 @@ async function waitFor(callback) {
|
|
|
105
106
|
|
|
106
107
|
throw new Error("Timed out waiting for condition")
|
|
107
108
|
}
|
|
109
|
+
})
|