rollbridge 0.1.14 → 0.1.16
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/compose.yml +17 -0
- package/docs/cli.md +19 -0
- package/docs/troubleshooting.md +18 -0
- package/package.json +1 -1
- package/src/cli.js +55 -17
- package/src/daemon-runtime.js +316 -0
- package/src/daemon.js +5 -2
- package/src/managed-process.js +55 -13
- package/src/process-memory.js +41 -3
- package/test/completion.test.js +1 -0
- package/test/daemon-runtime.test.js +90 -0
- package/test/managed-process.test.js +77 -0
- package/test/package-metadata.test.js +1 -1
- package/test/process-memory.test.js +21 -1
- package/test/release-runtime-retention.test.js +357 -0
- package/test/rollbridge.test.js +3 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
import {once} from "node:events"
|
|
6
|
+
import fs from "node:fs/promises"
|
|
7
|
+
import net from "node:net"
|
|
8
|
+
import os from "node:os"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import test from "node:test"
|
|
11
|
+
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
+
|
|
14
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
15
|
+
const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
|
|
16
|
+
|
|
17
|
+
test("detached daemon survives deletion of the release-local Rollbridge installation", async () => {
|
|
18
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-retention-"))
|
|
19
|
+
const releaseA = path.join(root, "releases", "A")
|
|
20
|
+
const releaseB = path.join(root, "releases", "B")
|
|
21
|
+
const socketPath = path.join(root, "control.sock")
|
|
22
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
23
|
+
const logPath = path.join(root, "daemon.log")
|
|
24
|
+
const pidPath = path.join(root, "daemon.pid")
|
|
25
|
+
const runtimePath = path.join(root, "runtime")
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await Promise.all([prepareRelease(releaseA, true), prepareRelease(releaseB, true)])
|
|
29
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify({
|
|
30
|
+
application: "runtime-retention-test",
|
|
31
|
+
control: {path: socketPath},
|
|
32
|
+
processes: [{
|
|
33
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
34
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
35
|
+
id: "web",
|
|
36
|
+
policy: "proxied",
|
|
37
|
+
port: {from: 0, to: 0}
|
|
38
|
+
}],
|
|
39
|
+
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
|
|
40
|
+
}, null, 2)}\n`)
|
|
41
|
+
|
|
42
|
+
await runReleaseCli(releaseA, [
|
|
43
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
44
|
+
"--release-path", releaseA, "--release-id", "A",
|
|
45
|
+
"--daemon-log-path", logPath, "--daemon-pid-path", pidPath,
|
|
46
|
+
"--daemon-runtime-path", runtimePath
|
|
47
|
+
])
|
|
48
|
+
await runReleaseCli(releaseB, [
|
|
49
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
50
|
+
"--release-path", releaseB, "--release-id", "B",
|
|
51
|
+
"--daemon-log-path", logPath, "--daemon-pid-path", pidPath,
|
|
52
|
+
"--daemon-runtime-path", runtimePath
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
await fs.rm(path.join(releaseA, "node_modules"), {recursive: true})
|
|
56
|
+
|
|
57
|
+
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
58
|
+
const proxyPort = /** @type {{port: number}} */ (status.proxy).port
|
|
59
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/deferred-runtime`)
|
|
60
|
+
const runtime = /** @type {{digest: string, format: number, path: string, version: string}} */ (status.daemonRuntime)
|
|
61
|
+
|
|
62
|
+
assert.equal(status.activeReleaseId, "B")
|
|
63
|
+
assert.equal(runtime.format, 1)
|
|
64
|
+
assert.match(runtime.digest, /^[a-f0-9]{64}$/)
|
|
65
|
+
assert.equal(path.dirname(runtime.path), runtimePath)
|
|
66
|
+
assert.ok(!runtime.path.startsWith(releaseA), `runtime must be outside release A: ${runtime.path}`)
|
|
67
|
+
assert.equal(response.status, 200)
|
|
68
|
+
assert.equal(await response.text(), "deferred runtime loaded\n")
|
|
69
|
+
} finally {
|
|
70
|
+
try {
|
|
71
|
+
await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
72
|
+
} catch {
|
|
73
|
+
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
74
|
+
if (Number.isInteger(pid)) process.kill(pid, "SIGKILL")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("runtime preparation failure prevents daemon startup and deploy handoff", async () => {
|
|
82
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-failure-"))
|
|
83
|
+
const release = path.join(root, "release")
|
|
84
|
+
const socketPath = path.join(root, "control.sock")
|
|
85
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
86
|
+
const invalidRuntimePath = path.join(root, "not-a-directory")
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await prepareRelease(release, false)
|
|
90
|
+
await fs.writeFile(invalidRuntimePath, "occupied\n")
|
|
91
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
|
|
92
|
+
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
() => runReleaseCli(release, [
|
|
95
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
96
|
+
"--release-path", release, "--release-id", "blocked",
|
|
97
|
+
"--daemon-runtime-path", invalidRuntimePath
|
|
98
|
+
]),
|
|
99
|
+
/EEXIST|not a directory|ENOTDIR/
|
|
100
|
+
)
|
|
101
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
102
|
+
} finally {
|
|
103
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("ensure-daemon refuses a responsive legacy daemon before deploy", async () => {
|
|
108
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-legacy-"))
|
|
109
|
+
const release = path.join(root, "release")
|
|
110
|
+
const socketPath = path.join(root, "control.sock")
|
|
111
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
112
|
+
let deployReceived = false
|
|
113
|
+
const legacyDaemon = net.createServer((socket) => {
|
|
114
|
+
socket.setEncoding("utf8")
|
|
115
|
+
socket.on("data", (contents) => {
|
|
116
|
+
const command = JSON.parse(String(contents).trim())
|
|
117
|
+
|
|
118
|
+
if (command.command === "deploy") deployReceived = true
|
|
119
|
+
socket.end(`${JSON.stringify({activeReleaseId: null, application: "runtime-retention-test", releases: []})}\n`)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await prepareRelease(release, false)
|
|
125
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
|
|
126
|
+
await new Promise((resolve, reject) => {
|
|
127
|
+
legacyDaemon.once("error", reject)
|
|
128
|
+
legacyDaemon.listen(socketPath, () => resolve(undefined))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
await assert.rejects(
|
|
132
|
+
() => runReleaseCli(release, [
|
|
133
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
134
|
+
"--release-path", release, "--release-id", "must-not-deploy",
|
|
135
|
+
"--daemon-runtime-path", path.join(root, "runtime")
|
|
136
|
+
]),
|
|
137
|
+
/legacy or mismatched runtime.*deploy was not sent/s
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
assert.equal(deployReceived, false)
|
|
141
|
+
} finally {
|
|
142
|
+
await new Promise((resolve) => legacyDaemon.close(resolve))
|
|
143
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("ensure-daemon refuses a mismatched runtime attestation before deploy", async () => {
|
|
148
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-mismatch-"))
|
|
149
|
+
const release = path.join(root, "release")
|
|
150
|
+
const socketPath = path.join(root, "control.sock")
|
|
151
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
152
|
+
let deployReceived = false
|
|
153
|
+
const daemon = net.createServer((socket) => {
|
|
154
|
+
socket.setEncoding("utf8")
|
|
155
|
+
socket.on("data", (contents) => {
|
|
156
|
+
const command = JSON.parse(String(contents).trim())
|
|
157
|
+
|
|
158
|
+
if (command.command === "deploy") deployReceived = true
|
|
159
|
+
socket.end(`${JSON.stringify({
|
|
160
|
+
activeReleaseId: "current",
|
|
161
|
+
application: "runtime-retention-test",
|
|
162
|
+
daemonRuntime: {digest: "0".repeat(64), format: 1, path: "/durable/runtime", version: "0.1.14"},
|
|
163
|
+
releases: []
|
|
164
|
+
})}\n`)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await prepareRelease(release, false)
|
|
170
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
daemon.once("error", reject)
|
|
173
|
+
daemon.listen(socketPath, () => resolve(undefined))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await assert.rejects(
|
|
177
|
+
() => runReleaseCli(release, [
|
|
178
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
179
|
+
"--release-path", release, "--release-id", "must-not-deploy",
|
|
180
|
+
"--daemon-runtime-path", path.join(root, "runtime")
|
|
181
|
+
]),
|
|
182
|
+
/legacy or mismatched runtime.*deploy was not sent/s
|
|
183
|
+
)
|
|
184
|
+
assert.equal(deployReceived, false)
|
|
185
|
+
} finally {
|
|
186
|
+
await new Promise((resolve) => daemon.close(resolve))
|
|
187
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("concurrent startup loser re-attests the winner before sending deploy", async () => {
|
|
192
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-start-race-"))
|
|
193
|
+
const winnerRelease = path.join(root, "releases", "winner")
|
|
194
|
+
const loserRelease = path.join(root, "releases", "loser")
|
|
195
|
+
const socketPath = path.join(root, "control.sock")
|
|
196
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
197
|
+
const loserPausedPath = path.join(root, "loser-paused")
|
|
198
|
+
const winnerPidPath = path.join(root, "winner.pid")
|
|
199
|
+
const loserPidPath = path.join(root, "loser.pid")
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await Promise.all([prepareRelease(winnerRelease, false), prepareRelease(loserRelease, false)])
|
|
203
|
+
await installStartupPause(loserRelease, loserPausedPath)
|
|
204
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify({
|
|
205
|
+
application: "runtime-start-race-test",
|
|
206
|
+
control: {path: socketPath},
|
|
207
|
+
processes: [{
|
|
208
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
209
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
210
|
+
id: "web",
|
|
211
|
+
policy: "proxied",
|
|
212
|
+
port: {from: 0, to: 0}
|
|
213
|
+
}],
|
|
214
|
+
proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
|
|
215
|
+
}, null, 2)}\n`)
|
|
216
|
+
|
|
217
|
+
const loserDeploy = runReleaseCli(loserRelease, [
|
|
218
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
219
|
+
"--release-path", loserRelease, "--release-id", "loser",
|
|
220
|
+
"--daemon-log-path", path.join(root, "loser.log"), "--daemon-pid-path", loserPidPath,
|
|
221
|
+
"--daemon-runtime-path", path.join(root, "loser-runtime")
|
|
222
|
+
])
|
|
223
|
+
|
|
224
|
+
await waitForFile(loserPausedPath)
|
|
225
|
+
await runReleaseCli(winnerRelease, [
|
|
226
|
+
"deploy", "--ensure-daemon", "--config", configPath,
|
|
227
|
+
"--release-path", winnerRelease, "--release-id", "winner",
|
|
228
|
+
"--daemon-log-path", path.join(root, "winner.log"), "--daemon-pid-path", winnerPidPath,
|
|
229
|
+
"--daemon-runtime-path", path.join(root, "winner-runtime")
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
await assert.rejects(loserDeploy, /legacy or mismatched runtime.*deploy was not sent/s)
|
|
233
|
+
const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
234
|
+
|
|
235
|
+
assert.equal(status.activeReleaseId, "winner")
|
|
236
|
+
} finally {
|
|
237
|
+
try {
|
|
238
|
+
await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
239
|
+
} catch {
|
|
240
|
+
// The winning daemon may have failed before accepting commands.
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const pidPath of [winnerPidPath, loserPidPath]) {
|
|
244
|
+
const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
|
|
245
|
+
|
|
246
|
+
if (Number.isInteger(pid)) {
|
|
247
|
+
try { process.kill(pid, "SIGKILL") } catch { /* The process already exited. */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Creates a release-local Rollbridge package with production dependencies and,
|
|
257
|
+
* for release A, a daemon module that performs one deliberately deferred import.
|
|
258
|
+
* @param {string} releasePath - Release directory.
|
|
259
|
+
* @param {boolean} deferredImport - Whether to add the deferred daemon route.
|
|
260
|
+
* @returns {Promise<void>} Resolves when prepared.
|
|
261
|
+
*/
|
|
262
|
+
async function prepareRelease(releasePath, deferredImport) {
|
|
263
|
+
const packagePath = path.join(releasePath, "node_modules", "rollbridge")
|
|
264
|
+
|
|
265
|
+
await fs.mkdir(path.dirname(packagePath), {recursive: true})
|
|
266
|
+
await fs.cp(repoRoot, packagePath, {
|
|
267
|
+
filter: (source) => {
|
|
268
|
+
const relative = path.relative(repoRoot, source)
|
|
269
|
+
|
|
270
|
+
return relative !== ".git" && relative !== "node_modules" && relative !== "test" && relative !== "tmp"
|
|
271
|
+
},
|
|
272
|
+
recursive: true
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
for (const dependency of ["commander", "eventemitter3", "follow-redirects", "http-proxy", "requires-port"]) {
|
|
276
|
+
await fs.symlink(path.join(repoRoot, "node_modules", dependency), path.join(releasePath, "node_modules", dependency), "dir")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!deferredImport) return
|
|
280
|
+
|
|
281
|
+
const daemonPath = path.join(packagePath, "src", "daemon.js")
|
|
282
|
+
const source = await fs.readFile(daemonPath, "utf8")
|
|
283
|
+
const marker = " proxyHttp(request, response) {\n"
|
|
284
|
+
const deferredRoute = `${marker} if (request.url === "/deferred-runtime") {\n void import("./deferred-runtime.js")\n .then(({default: body}) => { response.writeHead(200); response.end(body) })\n .catch((error) => { response.writeHead(500); response.end(String(error)) })\n return\n }\n\n`
|
|
285
|
+
|
|
286
|
+
assert.ok(source.includes(marker))
|
|
287
|
+
await fs.writeFile(daemonPath, source.replace(marker, deferredRoute))
|
|
288
|
+
await fs.writeFile(path.join(packagePath, "src", "deferred-runtime.js"), "export default \"deferred runtime loaded\\n\"\n")
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Pauses a release after its initial missing-daemon probe so another runtime can win startup.
|
|
293
|
+
* @param {string} releasePath - Release directory.
|
|
294
|
+
* @param {string} pausedPath - Synchronization file.
|
|
295
|
+
* @returns {Promise<void>} Resolves after installing the test hook.
|
|
296
|
+
*/
|
|
297
|
+
async function installStartupPause(releasePath, pausedPath) {
|
|
298
|
+
const cliPath = path.join(releasePath, "node_modules", "rollbridge", "src", "cli.js")
|
|
299
|
+
const source = await fs.readFile(cliPath, "utf8")
|
|
300
|
+
const marker = " await startDaemonProcess({\n"
|
|
301
|
+
const pause = ` await fsPromises.writeFile(${JSON.stringify(pausedPath)}, "paused\\n")\n await new Promise((resolve) => setTimeout(resolve, 750))\n\n${marker}`
|
|
302
|
+
|
|
303
|
+
assert.ok(source.includes(marker))
|
|
304
|
+
await fs.writeFile(cliPath, source.replace(marker, pause))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {string} filePath - File to await.
|
|
309
|
+
* @returns {Promise<void>} Resolves when the file exists.
|
|
310
|
+
*/
|
|
311
|
+
async function waitForFile(filePath) {
|
|
312
|
+
const deadline = Date.now() + 5000
|
|
313
|
+
|
|
314
|
+
while (Date.now() < deadline) {
|
|
315
|
+
try {
|
|
316
|
+
await fs.stat(filePath)
|
|
317
|
+
return
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
throw new Error(`Timed out waiting for ${filePath}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {string} socketPath - Control socket path.
|
|
330
|
+
* @returns {Record<string, import("../src/json.js").JsonValue>} Minimal config.
|
|
331
|
+
*/
|
|
332
|
+
function basicConfig(socketPath) {
|
|
333
|
+
return {
|
|
334
|
+
application: "runtime-retention-test",
|
|
335
|
+
control: {path: socketPath},
|
|
336
|
+
processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
|
|
337
|
+
proxy: {host: "127.0.0.1", port: 0}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Runs a release-local Rollbridge CLI to completion.
|
|
343
|
+
* @param {string} releasePath - Release directory.
|
|
344
|
+
* @param {string[]} args - CLI arguments.
|
|
345
|
+
* @returns {Promise<void>} Resolves on success.
|
|
346
|
+
*/
|
|
347
|
+
async function runReleaseCli(releasePath, args) {
|
|
348
|
+
const binPath = path.join(releasePath, "node_modules", "rollbridge", "bin", "rollbridge")
|
|
349
|
+
const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
350
|
+
let output = ""
|
|
351
|
+
|
|
352
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
353
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
354
|
+
const [code] = await once(child, "exit")
|
|
355
|
+
|
|
356
|
+
if (code !== 0) throw new Error(output)
|
|
357
|
+
}
|
package/test/rollbridge.test.js
CHANGED
|
@@ -1117,7 +1117,9 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1117
1117
|
"--daemon-log-path",
|
|
1118
1118
|
logPath,
|
|
1119
1119
|
"--daemon-pid-path",
|
|
1120
|
-
pidPath
|
|
1120
|
+
pidPath,
|
|
1121
|
+
"--daemon-runtime-path",
|
|
1122
|
+
path.join(fixture.root, "daemon-runtime")
|
|
1121
1123
|
])
|
|
1122
1124
|
|
|
1123
1125
|
const status = await sendControlCommand({
|