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,27 +1,36 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import net from "node:net"
|
|
6
5
|
import os from "node:os"
|
|
7
6
|
import path from "node:path"
|
|
8
|
-
import
|
|
7
|
+
import {afterAll, beforeAll, describe, expect, test} from "@velocious/testing"
|
|
8
|
+
import {fileURLToPath} from "node:url"
|
|
9
9
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
10
10
|
import {normalizeConfig} from "../src/config.js"
|
|
11
11
|
import {sendControlCommand} from "../src/control-client.js"
|
|
12
12
|
|
|
13
|
+
describe("control-protocol", () => {
|
|
14
|
+
|
|
13
15
|
let root = ""
|
|
14
16
|
let socketPath = ""
|
|
15
17
|
let daemon = /** @type {RollbridgeDaemon | undefined} */ (undefined)
|
|
18
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
20
|
+
const runningProcessCommand = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
beforeAll(async () => {
|
|
18
23
|
root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-control-"))
|
|
19
24
|
socketPath = path.join(root, "rollbridge.sock")
|
|
20
25
|
|
|
21
26
|
const config = normalizeConfig({
|
|
22
27
|
application: "rollbridge-control-test",
|
|
23
28
|
control: {path: socketPath},
|
|
24
|
-
processes: [
|
|
29
|
+
processes: [
|
|
30
|
+
{command: runningProcessCommand, id: "beacon", policy: "service", port: {from: 0, to: 0}},
|
|
31
|
+
{command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`, health: {intervalMs: 50, path: "/ping", timeoutMs: 3000}, id: "web", policy: "proxied", port: {from: 0, to: 0}},
|
|
32
|
+
{command: runningProcessCommand, id: "jobs-main", policy: "singleton"}
|
|
33
|
+
],
|
|
25
34
|
proxy: {host: "127.0.0.1", port: 0}
|
|
26
35
|
})
|
|
27
36
|
|
|
@@ -29,7 +38,7 @@ before(async () => {
|
|
|
29
38
|
await daemon.start()
|
|
30
39
|
})
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
afterAll(async () => {
|
|
33
42
|
if (daemon) await daemon.shutdown()
|
|
34
43
|
await fs.rm(root, {force: true, recursive: true})
|
|
35
44
|
})
|
|
@@ -63,32 +72,84 @@ async function sendRawControlLine(rawLine) {
|
|
|
63
72
|
test("malformed JSON returns an error response without crashing the daemon", async () => {
|
|
64
73
|
const response = await sendRawControlLine("this is not json")
|
|
65
74
|
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
expect(response.status).toBe("error")
|
|
76
|
+
expect(String(response.error)).toMatch(/JSON/)
|
|
68
77
|
|
|
69
78
|
// The daemon stays up and still answers valid commands afterwards.
|
|
70
79
|
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
expect(status.application).toBe("rollbridge-control-test")
|
|
73
82
|
})
|
|
74
83
|
|
|
75
84
|
test("non-object JSON is rejected as an invalid control command", async () => {
|
|
76
85
|
const response = await sendRawControlLine("123")
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
expect(response.status).toBe("error")
|
|
88
|
+
expect(response.error).toBe("Control command must be an object")
|
|
80
89
|
})
|
|
81
90
|
|
|
82
91
|
test("an unknown control command returns a clear error", async () => {
|
|
83
92
|
const response = await sendRawControlLine(JSON.stringify({command: "bogus"}))
|
|
84
93
|
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
expect(response.status).toBe("error")
|
|
95
|
+
expect(response.error).toBe("Unknown command: bogus")
|
|
87
96
|
})
|
|
88
97
|
|
|
89
98
|
test("a known command missing a required field returns a field error", async () => {
|
|
90
99
|
const response = await sendRawControlLine(JSON.stringify({command: "deploy"}))
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
expect(response.status).toBe("error")
|
|
102
|
+
expect(response.error).toBe("releasePath is required")
|
|
103
|
+
})
|
|
104
|
+
test("status can omit process logs without changing the default status payload", async () => {
|
|
105
|
+
if (!daemon) throw new Error("Missing required fixture: daemon")
|
|
106
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
107
|
+
|
|
108
|
+
const full = /** @type {import("../src/daemon.js").DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: socketPath}))
|
|
109
|
+
const compact = /** @type {import("../src/daemon.js").DaemonStatusWithoutLogs} */ (await sendControlCommand({command: {command: "status", includeLogs: false}, path: socketPath}))
|
|
110
|
+
const invalid = await sendRawControlLine(JSON.stringify({command: "status", includeLogs: "false"}))
|
|
111
|
+
|
|
112
|
+
expect(full.releases[0]?.processes.every((processStatus) => Array.isArray(processStatus.logs))).toBeTruthy()
|
|
113
|
+
expect(full.services.every(({process: processStatus}) => Array.isArray(processStatus.logs))).toBeTruthy()
|
|
114
|
+
expect(full.singletons.every(({process: processStatus}) => Array.isArray(processStatus.logs))).toBeTruthy()
|
|
115
|
+
expect(statusWithoutProcessUptimes(compact)).toEqual(statusWithoutProcessUptimes({
|
|
116
|
+
...full,
|
|
117
|
+
releases: full.releases.map((release) => ({
|
|
118
|
+
...release,
|
|
119
|
+
processes: release.processes.map(({logs: _logs, ...processStatus}) => processStatus)
|
|
120
|
+
})),
|
|
121
|
+
services: full.services.map(({process, ...service}) => ({
|
|
122
|
+
...service,
|
|
123
|
+
process: (({logs: _logs, ...processStatus}) => processStatus)(process)
|
|
124
|
+
})),
|
|
125
|
+
singletons: full.singletons.map(({process, ...singleton}) => ({
|
|
126
|
+
...singleton,
|
|
127
|
+
process: (({logs: _logs, ...processStatus}) => processStatus)(process)
|
|
128
|
+
}))
|
|
129
|
+
}))
|
|
130
|
+
expect(invalid.status).toBe("error")
|
|
131
|
+
expect(invalid.error).toBe("includeLogs must be a boolean")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {import("../src/daemon.js").DaemonStatus | import("../src/daemon.js").DaemonStatusWithoutLogs} status - Status response.
|
|
136
|
+
* @returns {Record<string, import("../src/json.js").JsonValue>} Status without volatile process uptime.
|
|
137
|
+
*/
|
|
138
|
+
function statusWithoutProcessUptimes(status) {
|
|
139
|
+
return {
|
|
140
|
+
...status,
|
|
141
|
+
releases: status.releases.map((release) => ({
|
|
142
|
+
...release,
|
|
143
|
+
processes: release.processes.map(({uptimeMs: _uptimeMs, ...processStatus}) => processStatus)
|
|
144
|
+
})),
|
|
145
|
+
services: status.services.map(({process, ...service}) => ({
|
|
146
|
+
...service,
|
|
147
|
+
process: (({uptimeMs: _uptimeMs, ...processStatus}) => processStatus)(process)
|
|
148
|
+
})),
|
|
149
|
+
singletons: status.singletons.map(({process, ...singleton}) => ({
|
|
150
|
+
...singleton,
|
|
151
|
+
process: (({uptimeMs: _uptimeMs, ...processStatus}) => processStatus)(process)
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
94
155
|
})
|
|
@@ -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 net from "node:net"
|
|
8
7
|
import os from "node:os"
|
|
9
8
|
import path from "node:path"
|
|
10
|
-
import test from "
|
|
9
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
11
10
|
import {fileURLToPath} from "node:url"
|
|
12
11
|
import {sendControlCommand} from "../src/control-client.js"
|
|
13
12
|
import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state-store.js"
|
|
14
13
|
|
|
14
|
+
describe("daemon-bootstrap", () => {
|
|
15
|
+
|
|
15
16
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
16
17
|
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
17
18
|
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
@@ -21,43 +22,39 @@ const secondAttestation = `sha256:${"b".repeat(64)}`
|
|
|
21
22
|
|
|
22
23
|
/** @typedef {{data?: Record<string, import("../src/json.js").JsonValue>, message?: string}} StructuredRecord */
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} finally {
|
|
58
|
-
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
59
|
-
}
|
|
60
|
-
})
|
|
25
|
+
const invalidBootstrapCases = [
|
|
26
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
|
|
27
|
+
{args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
|
|
28
|
+
{args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
|
|
29
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
|
|
30
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
|
|
31
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
|
|
32
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/},
|
|
33
|
+
{args: ["--config", "CONFIG", "--boot-attestation", firstAttestation], message: /accepted only with/},
|
|
34
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha256:${"A".repeat(64)}`], message: /--boot-attestation/},
|
|
35
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha512:${"a".repeat(64)}`], message: /--boot-attestation/},
|
|
36
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha256:${"a".repeat(63)}`], message: /--boot-attestation/}
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
test.each(invalidBootstrapCases)("daemon bootstrap rejects invalid startup arguments (%#)", async (/** @type {{args: string[], message: RegExp}} */ testCase) => {
|
|
40
|
+
const fixture = await createFixture()
|
|
41
|
+
const args = testCase.args.map((arg) => {
|
|
42
|
+
if (arg === "CONFIG") return fixture.configPath
|
|
43
|
+
if (arg === "RELEASE") return fixture.root
|
|
44
|
+
if (arg === "RELEASE_UNNORMALIZED") return `${fixture.root}/child/..`
|
|
45
|
+
if (arg === "RELEASE_MISSING") return path.join(fixture.root, "missing")
|
|
46
|
+
return arg
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await runDaemon(args)
|
|
51
|
+
|
|
52
|
+
expect(result.code).not.toBe(0)
|
|
53
|
+
expect(result.stderr).toMatch(testCase.message)
|
|
54
|
+
await expect(fs.stat(fixture.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
55
|
+
await expect(fs.stat(fixture.startedPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
56
|
+
} finally {
|
|
57
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
61
58
|
}
|
|
62
59
|
})
|
|
63
60
|
|
|
@@ -70,19 +67,19 @@ test("daemon bootstrap activates the exact release through the foreground daemon
|
|
|
70
67
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
71
68
|
const activeRelease = assertRelease(status, "release-42")
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
expect(activeRelease.releasePath).toBe(fixture.root)
|
|
71
|
+
expect(activeRelease.revision).toBe("abc123")
|
|
72
|
+
expect(status.bootstrap).toEqual({
|
|
76
73
|
attestation: firstAttestation,
|
|
77
74
|
releaseId: "release-42",
|
|
78
75
|
releasePath: fixture.root,
|
|
79
76
|
revision: "abc123"
|
|
80
77
|
})
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
if (!(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")) throw new Error("Expected proxy status with a numeric port")
|
|
79
|
+
expect((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim()).toBe("release-42")
|
|
83
80
|
|
|
84
81
|
child.kill("SIGTERM")
|
|
85
|
-
|
|
82
|
+
expect((await once(child, "exit"))[0]).toBe(0)
|
|
86
83
|
} finally {
|
|
87
84
|
if (child.exitCode === null) child.kill("SIGKILL")
|
|
88
85
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -110,21 +107,23 @@ test("failed takeover bootstrap preserves the previously accepted owner", async
|
|
|
110
107
|
"--takeover-owner"
|
|
111
108
|
])
|
|
112
109
|
|
|
113
|
-
|
|
110
|
+
expect(result.code).not.toBe(0)
|
|
114
111
|
const records = parseStructuredOutput(result.output)
|
|
115
112
|
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
116
113
|
const candidatePid = Number(await fs.readFile(fixture.startedPath, "utf8"))
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
expect(String(failure?.data?.error)).toMatch(/Health check failed/)
|
|
116
|
+
expect(String(failure?.data?.stack)).toMatch(/Error: Health check failed/)
|
|
117
|
+
// The failed candidate process must be stopped.
|
|
118
|
+
expect(isProcessAlive(candidatePid)).toBe(false)
|
|
121
119
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
expect(status.activeReleaseId).toBe("accepted")
|
|
121
|
+
if (!(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))) throw new Error("Expected bootstrap status")
|
|
122
|
+
expect(status.bootstrap.attestation).toBe(firstAttestation)
|
|
125
123
|
const priorState = await readState(fixture.statePath)
|
|
126
124
|
|
|
127
|
-
|
|
125
|
+
// Candidate cleanup must preserve the prior owner's state.
|
|
126
|
+
expect(priorState && typeof priorState === "object" && !Array.isArray(priorState) && priorState.activeReleaseId === "accepted").toBeTruthy()
|
|
128
127
|
} finally {
|
|
129
128
|
accepted.kill("SIGTERM")
|
|
130
129
|
if (accepted.exitCode === null) await once(accepted, "exit")
|
|
@@ -139,20 +138,20 @@ test("daemon bootstrap does not expose control deploys until activation complete
|
|
|
139
138
|
|
|
140
139
|
try {
|
|
141
140
|
await started
|
|
142
|
-
await
|
|
141
|
+
await expect(fs.stat(fixture.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
143
142
|
|
|
144
143
|
await fs.writeFile(fixture.healthGatePath, "ready\n")
|
|
145
144
|
await waitForLog(child, "control socket listening")
|
|
146
145
|
|
|
147
146
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
expect(status.activeReleaseId).toBe("bootstrap-release")
|
|
149
|
+
if (!Array.isArray(status.releases)) throw new Error("Expected release statuses")
|
|
150
|
+
expect(status.releases.length).toBe(1)
|
|
152
151
|
assertRelease(status, "bootstrap-release")
|
|
153
152
|
|
|
154
153
|
child.kill("SIGTERM")
|
|
155
|
-
|
|
154
|
+
expect((await once(child, "exit"))[0]).toBe(0)
|
|
156
155
|
} finally {
|
|
157
156
|
if (child.exitCode === null) child.kill("SIGKILL")
|
|
158
157
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -167,12 +166,12 @@ test("plain daemon startup remains listener-only with no active release", async
|
|
|
167
166
|
await waitForLog(child, "control socket listening")
|
|
168
167
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
expect(status.activeReleaseId).toBe(null)
|
|
170
|
+
expect(status.releases).toEqual([])
|
|
171
|
+
expect(status.bootstrap).toBe(undefined)
|
|
173
172
|
|
|
174
173
|
child.kill("SIGTERM")
|
|
175
|
-
|
|
174
|
+
expect((await once(child, "exit"))[0]).toBe(0)
|
|
176
175
|
} finally {
|
|
177
176
|
if (child.exitCode === null) child.kill("SIGKILL")
|
|
178
177
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -185,10 +184,10 @@ test("ensure-daemon rejects boot attestation instead of inheriting foreground id
|
|
|
185
184
|
try {
|
|
186
185
|
const result = await runRollbridge(["ensure-daemon", "--config", fixture.configPath, "--boot-attestation", firstAttestation])
|
|
187
186
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await
|
|
191
|
-
await
|
|
187
|
+
expect(result.code).not.toBe(0)
|
|
188
|
+
expect(result.stderr).toMatch(/unknown option '--boot-attestation'/)
|
|
189
|
+
await expect(fs.stat(fixture.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
190
|
+
await expect(fs.stat(fixture.startedPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
192
191
|
} finally {
|
|
193
192
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
194
193
|
}
|
|
@@ -206,15 +205,15 @@ test("otherwise identical foreground boots remain distinguishable by attestation
|
|
|
206
205
|
await waitForLog(child, "control socket listening")
|
|
207
206
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
208
207
|
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
expect(status.activeReleaseId).toBe("same-release")
|
|
209
|
+
if (!(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))) throw new Error("Expected bootstrap status")
|
|
211
210
|
attestations.push(status.bootstrap.attestation)
|
|
212
211
|
|
|
213
212
|
child.kill("SIGTERM")
|
|
214
|
-
|
|
213
|
+
expect((await once(child, "exit"))[0]).toBe(0)
|
|
215
214
|
}
|
|
216
215
|
|
|
217
|
-
|
|
216
|
+
expect(attestations).toEqual([firstAttestation, secondAttestation])
|
|
218
217
|
} finally {
|
|
219
218
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
220
219
|
}
|
|
@@ -231,10 +230,10 @@ test("SIGTERM during bootstrap activation follows the daemon shutdown path", asy
|
|
|
231
230
|
|
|
232
231
|
const [code, signal] = await once(child, "exit")
|
|
233
232
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
await
|
|
233
|
+
expect(code).toBe(0)
|
|
234
|
+
expect(signal).toBe(null)
|
|
235
|
+
expect(await fs.readFile(fixture.stoppedPath, "utf8")).toBe(String(managedPid))
|
|
236
|
+
await expect(fs.stat(fixture.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
238
237
|
} finally {
|
|
239
238
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
240
239
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -259,12 +258,12 @@ test("SIGTERM during multi-process bootstrap owns every process started after sh
|
|
|
259
258
|
const records = output.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
260
259
|
const recordedPids = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.pid))
|
|
261
260
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
for (const event of startedAfterShutdown)
|
|
261
|
+
expect(code).toBe(0)
|
|
262
|
+
expect(signal).toBe(null)
|
|
263
|
+
expect(shutdownIndex).not.toBe(-1)
|
|
264
|
+
expect({value: Boolean(startedAfterShutdown.length > 0), context: `fixture must start a later bootstrap process after triggering shutdown: ${JSON.stringify(events)}`}).toMatchObject({value: true})
|
|
265
|
+
expect(startedAfterShutdown.filter((event) => !recordedPids.has(event.pid))).toEqual([])
|
|
266
|
+
for (const event of startedAfterShutdown) expect({value: isProcessAlive(event.pid), context: `expected process ${event.pid} to be stopped before daemon exit`}).toMatchObject({value: false})
|
|
268
267
|
} finally {
|
|
269
268
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
270
269
|
|
|
@@ -295,13 +294,13 @@ test("failed ordinary bootstrap completely shuts down attempt-owned resources an
|
|
|
295
294
|
const records = parseStructuredOutput(result.output)
|
|
296
295
|
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
297
296
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
297
|
+
expect(result.code).not.toBe(0)
|
|
298
|
+
expect(failure?.data?.releaseId).toBe("ordinary-failure")
|
|
299
|
+
expect(failure?.data?.status).toBe("error")
|
|
300
|
+
expect(String(failure?.data?.error)).toMatch(/listen (?:EACCES|ENOENT)/)
|
|
301
|
+
expect(String(failure?.data?.stack)).toMatch(/Error: listen (?:EACCES|ENOENT)/)
|
|
303
302
|
await assertAttemptResourcesStopped(fixture, records)
|
|
304
|
-
await
|
|
303
|
+
await expect(fs.stat(fixture.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
305
304
|
} finally {
|
|
306
305
|
await killAttemptProcesses(fixture.lifecyclePath)
|
|
307
306
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -323,13 +322,13 @@ test("failed takeover retirement completely shuts down and exits non-zero instea
|
|
|
323
322
|
const records = parseStructuredOutput(result.output)
|
|
324
323
|
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
325
324
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
325
|
+
expect(result.code).not.toBe(0)
|
|
326
|
+
expect(failure?.data?.releaseId).toBe("orphaned-candidate")
|
|
327
|
+
expect(failure?.data?.status).toBe("error")
|
|
328
|
+
expect(String(failure?.data?.error)).toMatch(/connect ENOENT/)
|
|
329
|
+
expect(String(failure?.data?.stack)).toMatch(/Error: connect ENOENT/)
|
|
331
330
|
await assertAttemptResourcesStopped(fixture, records)
|
|
332
|
-
await
|
|
331
|
+
await expect(fs.stat(fixture.socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
333
332
|
} finally {
|
|
334
333
|
await killAttemptProcesses(fixture.lifecyclePath)
|
|
335
334
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -354,13 +353,13 @@ test("daemon bootstrap reports but does not kill a live process from statePath",
|
|
|
354
353
|
await waitForLog(child, "control socket listening")
|
|
355
354
|
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
356
355
|
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
expect(leftover.pid !== undefined && isProcessAlive(leftover.pid)).toBeTruthy()
|
|
357
|
+
expect(status.orphans).toEqual([{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
359
358
|
|
|
360
359
|
child.kill("SIGTERM")
|
|
361
360
|
await once(child, "exit")
|
|
362
361
|
|
|
363
|
-
|
|
362
|
+
expect(liveProcesses(await readState(fixture.statePath))).toEqual([{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
364
363
|
} finally {
|
|
365
364
|
if (child.exitCode === null) child.kill("SIGKILL")
|
|
366
365
|
leftover.kill("SIGKILL")
|
|
@@ -388,8 +387,8 @@ test("failed daemon bootstrap preserves prior live process records in statePath"
|
|
|
388
387
|
"--revision", "bad123"
|
|
389
388
|
])
|
|
390
389
|
|
|
391
|
-
|
|
392
|
-
|
|
390
|
+
expect(result.code).not.toBe(0)
|
|
391
|
+
expect(liveProcesses(await readState(fixture.statePath))).toEqual([{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
393
392
|
} finally {
|
|
394
393
|
leftover.kill("SIGKILL")
|
|
395
394
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -444,7 +443,7 @@ async function createFixture({attemptOwnedProcesses = false, fixedPorts = false,
|
|
|
444
443
|
const fifo = spawn("mkfifo", [gatePath])
|
|
445
444
|
const [code] = await once(fifo, "exit")
|
|
446
445
|
|
|
447
|
-
|
|
446
|
+
expect(code).toBe(0)
|
|
448
447
|
}
|
|
449
448
|
|
|
450
449
|
await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
@@ -578,11 +577,11 @@ async function assertAttemptResourcesStopped(fixture, records) {
|
|
|
578
577
|
const managedStarts = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.processId))
|
|
579
578
|
const managedExits = new Set(records.filter((record) => record.message === "process exited").map((record) => record.data?.processId))
|
|
580
579
|
|
|
581
|
-
|
|
582
|
-
|
|
580
|
+
expect(managedStarts).toEqual(expectedProcessIds)
|
|
581
|
+
expect(managedExits).toEqual(expectedProcessIds)
|
|
583
582
|
for (const event of started) {
|
|
584
|
-
|
|
585
|
-
|
|
583
|
+
expect({value: stoppedPids.has(event.pid), context: `${event.processId} must receive graceful shutdown`}).toMatchObject({value: true})
|
|
584
|
+
expect({value: isProcessAlive(Number(event.pid)), context: `${event.processId} pid ${event.pid} must be gone before daemon exit`}).toMatchObject({value: false})
|
|
586
585
|
}
|
|
587
586
|
|
|
588
587
|
await assertPortAvailable(fixture.processPort)
|
|
@@ -674,9 +673,10 @@ async function waitForLog(child, message) {
|
|
|
674
673
|
* @returns {Record<string, import("../src/json.js").JsonValue>} Matching release status.
|
|
675
674
|
*/
|
|
676
675
|
function assertRelease(status, releaseId) {
|
|
677
|
-
|
|
676
|
+
if (!Array.isArray(status.releases)) throw new Error("Expected release statuses")
|
|
678
677
|
const release = status.releases.find((candidate) => candidate && typeof candidate === "object" && "releaseId" in candidate && candidate.releaseId === releaseId)
|
|
679
678
|
|
|
680
|
-
|
|
679
|
+
if (!(release && typeof release === "object" && !Array.isArray(release))) throw new Error(`Release ${releaseId} should be present`)
|
|
681
680
|
return release
|
|
682
681
|
}
|
|
682
|
+
})
|
|
@@ -1,12 +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 {prepareDaemonRuntime} from "../src/daemon-runtime.js"
|
|
9
8
|
|
|
9
|
+
describe("daemon-runtime", () => {
|
|
10
|
+
|
|
11
|
+
const posixPermissionsTest = process.platform === "win32" ? test.skip : test
|
|
12
|
+
|
|
10
13
|
test("concurrent runtime preparation converges on one validated content-addressed snapshot", async () => {
|
|
11
14
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-concurrent-"))
|
|
12
15
|
|
|
@@ -17,13 +20,13 @@ test("concurrent runtime preparation converges on one validated content-addresse
|
|
|
17
20
|
prepareDaemonRuntime(root)
|
|
18
21
|
])
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
expect(identities).toEqual([identities[0], identities[0], identities[0]])
|
|
24
|
+
expect(identities[0].digest).toMatch(/^[a-f0-9]{64}$/)
|
|
25
|
+
expect(path.dirname(identities[0].path)).toBe(root)
|
|
26
|
+
expect(JSON.parse(await fs.readFile(path.join(identities[0].path, "runtime.json"), "utf8")).digest).toBe(identities[0].digest)
|
|
24
27
|
|
|
25
28
|
const entries = (await fs.readdir(root)).filter((entry) => entry.startsWith(".prepare-"))
|
|
26
|
-
|
|
29
|
+
expect(entries).toEqual([])
|
|
27
30
|
} finally {
|
|
28
31
|
await fs.rm(root, {force: true, recursive: true})
|
|
29
32
|
}
|
|
@@ -36,13 +39,13 @@ test("preparation fails closed when an existing content-addressed snapshot is co
|
|
|
36
39
|
const identity = await prepareDaemonRuntime(root)
|
|
37
40
|
|
|
38
41
|
await fs.writeFile(path.join(identity.path, "src", "daemon.js"), "corrupt\n")
|
|
39
|
-
await
|
|
42
|
+
await expect(prepareDaemonRuntime(root)).rejects.toThrow(/runtime validation failed/)
|
|
40
43
|
} finally {
|
|
41
44
|
await fs.rm(root, {force: true, recursive: true})
|
|
42
45
|
}
|
|
43
46
|
})
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
posixPermissionsTest("runtime preparation rejects a symlinked or shared-writable parent", async () => {
|
|
46
49
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-permissions-"))
|
|
47
50
|
const target = path.join(root, "target")
|
|
48
51
|
const symlink = path.join(root, "symlink")
|
|
@@ -51,27 +54,17 @@ test("runtime preparation rejects a symlinked or shared-writable parent", async
|
|
|
51
54
|
try {
|
|
52
55
|
await fs.mkdir(target)
|
|
53
56
|
await fs.symlink(target, symlink, "dir")
|
|
54
|
-
await
|
|
55
|
-
|
|
56
|
-
if (process.platform === "win32") {
|
|
57
|
-
t.skip("POSIX directory permissions are not available on Windows")
|
|
58
|
-
return
|
|
59
|
-
}
|
|
57
|
+
await expect(prepareDaemonRuntime(symlink)).rejects.toThrow(/must be a real directory/)
|
|
60
58
|
|
|
61
59
|
await fs.mkdir(shared, {mode: 0o777})
|
|
62
60
|
await fs.chmod(shared, 0o777)
|
|
63
|
-
await
|
|
61
|
+
await expect(prepareDaemonRuntime(shared)).rejects.toThrow(/must not be writable by group or other users/)
|
|
64
62
|
} finally {
|
|
65
63
|
await fs.rm(root, {force: true, recursive: true})
|
|
66
64
|
}
|
|
67
65
|
})
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
if (process.platform === "win32") {
|
|
71
|
-
t.skip("POSIX directory permissions are not available on Windows")
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
67
|
+
posixPermissionsTest("runtime preparation rejects a private leaf beneath a replaceable ancestor", async () => {
|
|
75
68
|
const unsafeAncestor = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-unsafe-ancestor-"))
|
|
76
69
|
const privateLeaf = path.join(unsafeAncestor, "private-runtime")
|
|
77
70
|
|
|
@@ -79,12 +72,10 @@ test("runtime preparation rejects a private leaf beneath a replaceable ancestor"
|
|
|
79
72
|
await fs.chmod(unsafeAncestor, 0o777)
|
|
80
73
|
await fs.mkdir(privateLeaf, {mode: 0o700})
|
|
81
74
|
|
|
82
|
-
await
|
|
83
|
-
() => prepareDaemonRuntime(privateLeaf),
|
|
84
|
-
/ancestor must be sticky or not writable by group or other users/
|
|
85
|
-
)
|
|
75
|
+
await expect(prepareDaemonRuntime(privateLeaf)).rejects.toThrow(/ancestor must be sticky or not writable by group or other users/)
|
|
86
76
|
} finally {
|
|
87
77
|
await fs.chmod(unsafeAncestor, 0o700)
|
|
88
78
|
await fs.rm(unsafeAncestor, {force: true, recursive: true})
|
|
89
79
|
}
|
|
90
80
|
})
|
|
81
|
+
})
|