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,19 +1,20 @@
|
|
|
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 {normalizeConfig} from "../src/config.js"
|
|
13
12
|
import {sendControlCommand} from "../src/control-client.js"
|
|
14
13
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
15
14
|
import {isProcessAlive, readState} from "../src/state-store.js"
|
|
16
15
|
|
|
16
|
+
describe("shutdown-completion", () => {
|
|
17
|
+
|
|
17
18
|
const dummyAppPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "dummy-app.js")
|
|
18
19
|
|
|
19
20
|
test("shutdown response waits for endpoint and owned-process cleanup before immediate replacement", async () => {
|
|
@@ -24,7 +25,7 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
|
|
|
24
25
|
const stoppingPath = path.join(root, "stopping")
|
|
25
26
|
const gate = spawn("mkfifo", [gatePath])
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
expect((await once(gate, "exit"))[0]).toBe(0)
|
|
28
29
|
|
|
29
30
|
const config = buildConfig(socketPath, {
|
|
30
31
|
companion: {
|
|
@@ -52,7 +53,7 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
|
|
|
52
53
|
|
|
53
54
|
const workerPid = daemon.activeRelease?.getProcess("worker")?.pid
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
expect(typeof workerPid).toBe("number")
|
|
56
57
|
|
|
57
58
|
const stopping = waitForFile(stoppingPath)
|
|
58
59
|
let shutdownResolved = false
|
|
@@ -85,23 +86,28 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
|
|
|
85
86
|
|
|
86
87
|
const response = await shutdown
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
expect(shutdownResolved).toBe(true)
|
|
90
|
+
// Shutdown must not acknowledge while an owned process is still stopping.
|
|
91
|
+
expect(resolvedDuringStop).toBe(false)
|
|
92
|
+
// The targeted endpoint must stop accepting new commands before cleanup.
|
|
93
|
+
expect(oldEndpointAccepted).toBe(false)
|
|
94
|
+
// The fixture must hold shutdown while its owned process is alive.
|
|
95
|
+
expect(processAliveDuringStop).toBe(true)
|
|
96
|
+
// An idle accepted client must be closed when the targeted endpoint retires.
|
|
97
|
+
expect(idleTargetClosedDuringStop).toBe(true)
|
|
98
|
+
// An unrelated daemon's accepted clients must remain untouched.
|
|
99
|
+
expect(idleUnrelatedClosedDuringStop).toBe(false)
|
|
100
|
+
expect(response).toEqual({message: "shutdown", status: "success"})
|
|
101
|
+
await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
102
|
+
expect(isProcessAlive(/** @type {number} */ (workerPid))).toBe(false)
|
|
97
103
|
|
|
98
104
|
// A different daemon remains reachable; shutdown is scoped to the targeted control endpoint.
|
|
99
|
-
|
|
105
|
+
expect((await sendControlCommand({command: {command: "status"}, path: unrelatedSocketPath})).application).toBe("shutdown-unrelated")
|
|
100
106
|
|
|
101
107
|
// Replacement starts immediately, with no polling or retry between truthful ACK and bind.
|
|
102
108
|
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
103
109
|
await replacement.start()
|
|
104
|
-
|
|
110
|
+
expect((await sendControlCommand({command: {command: "status"}, path: socketPath})).application).toBe("shutdown-target")
|
|
105
111
|
} finally {
|
|
106
112
|
if (!gateReleased) {
|
|
107
113
|
await fs.writeFile(gatePath, "continue\n").catch(() => {})
|
|
@@ -136,8 +142,8 @@ test("shutdown keeps daemon services alive until release-owned dependents stop",
|
|
|
136
142
|
const release = daemon.activeRelease
|
|
137
143
|
const coordinator = daemon.services.get("coordinator")
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
if (!release) throw new Error("Missing required fixture: release")
|
|
146
|
+
if (!coordinator) throw new Error("Missing required fixture: coordinator")
|
|
141
147
|
|
|
142
148
|
const originalReleaseStop = release.stop.bind(release)
|
|
143
149
|
const originalCoordinatorStop = coordinator.stop.bind(coordinator)
|
|
@@ -161,7 +167,8 @@ test("shutdown keeps daemon services alive until release-owned dependents stop",
|
|
|
161
167
|
releaseWorker()
|
|
162
168
|
await shutdown
|
|
163
169
|
|
|
164
|
-
|
|
170
|
+
// A worker must retain access to daemon services throughout its drain.
|
|
171
|
+
expect(serviceStoppedWhileWorkerWasDraining).toBe(false)
|
|
165
172
|
} finally {
|
|
166
173
|
releaseWorker()
|
|
167
174
|
await daemon.shutdown()
|
|
@@ -175,7 +182,7 @@ test("external-owner retirement releases listeners before a long-draining compan
|
|
|
175
182
|
const gatePath = path.join(root, "retire.fifo")
|
|
176
183
|
const stoppingPath = path.join(root, "stopping")
|
|
177
184
|
const gate = spawn("mkfifo", [gatePath])
|
|
178
|
-
|
|
185
|
+
expect((await once(gate, "exit"))[0]).toBe(0)
|
|
179
186
|
const config = buildConfig(socketPath, {companion: {
|
|
180
187
|
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
181
188
|
id: "worker",
|
|
@@ -190,14 +197,16 @@ test("external-owner retirement releases listeners before a long-draining compan
|
|
|
190
197
|
await daemon.deploy({releaseId: "old", releasePath: root, revision: "old"})
|
|
191
198
|
const retirement = sendControlCommand({command: {attestation: `sha256:${"a".repeat(64)}`, command: "retire-owner"}, path: socketPath})
|
|
192
199
|
const response = await retirement
|
|
193
|
-
|
|
194
|
-
|
|
200
|
+
expect(response).toEqual({message: "owner retired", status: "success"})
|
|
201
|
+
// Old worker must stop accepting work before listener takeover.
|
|
202
|
+
expect(await fs.readFile(stoppingPath, "utf8")).toBe("stopping")
|
|
195
203
|
|
|
196
204
|
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
197
205
|
await replacement.start({reportOrphans: false})
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
expect((await sendControlCommand({command: {command: "status"}, path: socketPath})).application).toBe("shutdown-target")
|
|
207
|
+
// Intentional retired companions are not replacement orphans.
|
|
208
|
+
expect(replacement.status().orphans).toEqual([])
|
|
209
|
+
expect(daemon.status().releases[0].processes[0].state).toBe("quiesced")
|
|
201
210
|
} finally {
|
|
202
211
|
await fs.writeFile(gatePath, "done\n").catch(() => {})
|
|
203
212
|
if (replacement) await replacement.shutdown()
|
|
@@ -227,13 +236,14 @@ test("a retired owner cannot clear replacement state during late shutdown", asyn
|
|
|
227
236
|
|
|
228
237
|
const replacementState = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
|
|
229
238
|
|
|
230
|
-
|
|
239
|
+
expect(replacementState?.activeReleaseId).toBe("replacement")
|
|
231
240
|
|
|
232
241
|
await retired.shutdown()
|
|
233
242
|
|
|
234
243
|
const stateAfterRetiredShutdown = /** @type {{activeReleaseId: string} | undefined} */ (await readState(statePath))
|
|
235
244
|
|
|
236
|
-
|
|
245
|
+
// Late shutdown of the retired owner must preserve replacement state.
|
|
246
|
+
expect(stateAfterRetiredShutdown?.activeReleaseId).toBe("replacement")
|
|
237
247
|
} finally {
|
|
238
248
|
if (replacement) await replacement.shutdown()
|
|
239
249
|
await retired.shutdown()
|
|
@@ -256,19 +266,17 @@ test("control socket unlink failure is reported only after owned cleanup complet
|
|
|
256
266
|
const webPid = daemon.activeRelease?.getProcess("web")?.pid
|
|
257
267
|
const proxyPort = daemon.getProxyPort()
|
|
258
268
|
|
|
259
|
-
|
|
260
|
-
|
|
269
|
+
expect(typeof webPid).toBe("number")
|
|
270
|
+
expect(typeof proxyPort).toBe("number")
|
|
261
271
|
|
|
262
272
|
daemon.removeControlSocket = async () => { throw new Error("injected unlink failure") }
|
|
263
273
|
|
|
264
|
-
await
|
|
265
|
-
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
266
|
-
/control socket unlink failed: injected unlink failure/
|
|
267
|
-
)
|
|
274
|
+
await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toThrow(/control socket unlink failed: injected unlink failure/)
|
|
268
275
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
await
|
|
276
|
+
// Unlink failure must not strand an owned process.
|
|
277
|
+
expect(isProcessAlive(/** @type {number} */ (webPid))).toBe(false)
|
|
278
|
+
await expect(fetch(`http://127.0.0.1:${proxyPort}/ping`)).rejects.toThrow()
|
|
279
|
+
await expect(fs.stat(statePath)).rejects.toMatchObject({code: "ENOENT"})
|
|
272
280
|
} finally {
|
|
273
281
|
await fs.rm(root, {force: true, recursive: true})
|
|
274
282
|
}
|
|
@@ -289,8 +297,8 @@ test("direct shutdown closes idle accepted clients and converges", async () => {
|
|
|
289
297
|
await daemon.shutdown()
|
|
290
298
|
await idleClosed
|
|
291
299
|
|
|
292
|
-
|
|
293
|
-
await
|
|
300
|
+
expect(idle.destroyed).toBe(true)
|
|
301
|
+
await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
294
302
|
} finally {
|
|
295
303
|
idle?.destroy()
|
|
296
304
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -313,11 +321,8 @@ test("shutdown reports cleanup failure and still retires the targeted endpoint",
|
|
|
313
321
|
try {
|
|
314
322
|
await daemon.start()
|
|
315
323
|
|
|
316
|
-
await
|
|
317
|
-
|
|
318
|
-
/directory|EISDIR/i
|
|
319
|
-
)
|
|
320
|
-
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
324
|
+
await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toThrow(/directory|EISDIR/i)
|
|
325
|
+
await expect(fs.stat(socketPath)).rejects.toMatchObject({code: "ENOENT"})
|
|
321
326
|
} finally {
|
|
322
327
|
await fs.rm(root, {force: true, recursive: true})
|
|
323
328
|
}
|
|
@@ -336,16 +341,13 @@ test("shutdown does not turn an owned-resource stop rejection into success", asy
|
|
|
336
341
|
|
|
337
342
|
const release = daemon.activeRelease
|
|
338
343
|
|
|
339
|
-
|
|
344
|
+
if (!release) throw new Error("Missing required fixture: release")
|
|
340
345
|
const originalStop = release.stop.bind(release)
|
|
341
346
|
|
|
342
347
|
restoreStop = originalStop
|
|
343
348
|
release.stop = async () => { throw new Error("owned release stop failed") }
|
|
344
349
|
|
|
345
|
-
await
|
|
346
|
-
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
347
|
-
/Shutdown failed to stop 1 owned resource/
|
|
348
|
-
)
|
|
350
|
+
await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toThrow(/Shutdown failed to stop 1 owned resource/)
|
|
349
351
|
} finally {
|
|
350
352
|
if (restoreStop) await restoreStop()
|
|
351
353
|
await fs.rm(root, {force: true, recursive: true})
|
|
@@ -357,10 +359,7 @@ test("shutdown of an already-stopped endpoint fails explicitly", async () => {
|
|
|
357
359
|
const socketPath = path.join(root, "missing.sock")
|
|
358
360
|
|
|
359
361
|
try {
|
|
360
|
-
await
|
|
361
|
-
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
362
|
-
(error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
363
|
-
)
|
|
362
|
+
await expect(sendControlCommand({command: {command: "shutdown"}, path: socketPath})).rejects.toMatchObject({code: "ENOENT"})
|
|
364
363
|
} finally {
|
|
365
364
|
await fs.rm(root, {force: true, recursive: true})
|
|
366
365
|
}
|
|
@@ -414,3 +413,4 @@ async function waitForFile(filePath) {
|
|
|
414
413
|
|
|
415
414
|
throw new Error(`Watcher ended before ${filePath} appeared`)
|
|
416
415
|
}
|
|
416
|
+
})
|
package/test/state-store.test.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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 {clearState, readState, writeState} from "../src/state-store.js"
|
|
9
8
|
|
|
9
|
+
describe("state-store", () => {
|
|
10
|
+
|
|
10
11
|
test("writeState then readState round-trips a snapshot", async () => {
|
|
11
12
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-state-"))
|
|
12
13
|
const statePath = path.join(dir, "state.json")
|
|
@@ -16,7 +17,7 @@ test("writeState then readState round-trips a snapshot", async () => {
|
|
|
16
17
|
|
|
17
18
|
const state = /** @type {{activeReleaseId: string}} */ (await readState(statePath))
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
expect(state.activeReleaseId).toBe("v1")
|
|
20
21
|
} finally {
|
|
21
22
|
await fs.rm(dir, {force: true, recursive: true})
|
|
22
23
|
}
|
|
@@ -28,7 +29,7 @@ test("writeState keeps durable guardian capabilities private", async () => {
|
|
|
28
29
|
|
|
29
30
|
try {
|
|
30
31
|
await writeState(statePath, {recovery: {guardian: {token: "private"}}})
|
|
31
|
-
|
|
32
|
+
expect((await fs.stat(statePath)).mode & 0o777).toBe(0o600)
|
|
32
33
|
} finally {
|
|
33
34
|
await fs.rm(dir, {force: true, recursive: true})
|
|
34
35
|
}
|
|
@@ -39,11 +40,11 @@ test("readState returns undefined for a missing or unparseable file", async () =
|
|
|
39
40
|
const statePath = path.join(dir, "state.json")
|
|
40
41
|
|
|
41
42
|
try {
|
|
42
|
-
|
|
43
|
+
expect(await readState(statePath)).toBe(undefined)
|
|
43
44
|
|
|
44
45
|
await fs.writeFile(statePath, "{not json")
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
expect(await readState(statePath)).toBe(undefined)
|
|
47
48
|
} finally {
|
|
48
49
|
await fs.rm(dir, {force: true, recursive: true})
|
|
49
50
|
}
|
|
@@ -59,7 +60,7 @@ test("concurrent writes leave a complete, uncorrupted snapshot", async () => {
|
|
|
59
60
|
const state = /** @type {{n: number}} */ (await readState(statePath))
|
|
60
61
|
|
|
61
62
|
// A complete snapshot from one of the writers — never a partial/corrupt file or a temp race.
|
|
62
|
-
|
|
63
|
+
expect(state && typeof state.n === "number").toBeTruthy()
|
|
63
64
|
} finally {
|
|
64
65
|
await fs.rm(dir, {force: true, recursive: true})
|
|
65
66
|
}
|
|
@@ -73,9 +74,10 @@ test("clearState removes the file and ignores a missing one", async () => {
|
|
|
73
74
|
await writeState(statePath, {ok: true})
|
|
74
75
|
await clearState(statePath)
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
expect(await readState(statePath)).toBe(undefined)
|
|
77
78
|
await clearState(statePath)
|
|
78
79
|
} finally {
|
|
79
80
|
await fs.rm(dir, {force: true, recursive: true})
|
|
80
81
|
}
|
|
81
82
|
})
|
|
83
|
+
})
|
package/test/system-ids.test.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
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 {resolveGroupId, resolveUserId} from "../src/system-ids.js"
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
describe("system-ids", () => {
|
|
7
|
+
|
|
8
|
+
const linuxTest = process.platform === "linux" ? test : test.skip
|
|
8
9
|
|
|
9
10
|
test("resolves numeric ids and numeric strings as-is", () => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
expect(resolveUserId(1000)).toBe(1000)
|
|
12
|
+
expect(resolveUserId("1000")).toBe(1000)
|
|
13
|
+
expect(resolveGroupId(0)).toBe(0)
|
|
14
|
+
expect(resolveGroupId("42")).toBe(42)
|
|
14
15
|
})
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
linuxTest("resolves user and group names to ids", () => {
|
|
18
|
+
expect(resolveUserId("root")).toBe(0)
|
|
19
|
+
expect(resolveGroupId("root")).toBe(0)
|
|
19
20
|
})
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
linuxTest("throws for an unknown user or group name", async () => {
|
|
23
|
+
await expect(() => resolveUserId("rollbridge-no-such-user")).toThrow(/Unknown user "rollbridge-no-such-user"/)
|
|
24
|
+
await expect(() => resolveGroupId("rollbridge-no-such-group")).toThrow(/Unknown group "rollbridge-no-such-group"/)
|
|
25
|
+
})
|
|
24
26
|
})
|