rollbridge 0.1.1
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/AI_TODO_PROMPT.md +39 -0
- package/README.md +200 -0
- package/TODO.md +96 -0
- package/bin/rollbridge +4 -0
- package/eslint.config.js +99 -0
- package/examples/tensorbuzz.com.js +85 -0
- package/package.json +32 -0
- package/scripts/release-patch.js +83 -0
- package/src/cli.js +359 -0
- package/src/config.js +414 -0
- package/src/control-client.js +42 -0
- package/src/daemon.js +575 -0
- package/src/health.js +31 -0
- package/src/json.d.ts +3 -0
- package/src/json.js +5 -0
- package/src/managed-process.js +232 -0
- package/src/port-allocator.js +88 -0
- package/src/release-group.js +287 -0
- package/src/template.js +74 -0
- package/tensorbuzz.yml +4 -0
- package/test/config-examples.test.js +59 -0
- package/test/config-path.test.js +90 -0
- package/test/config-validation.test.js +156 -0
- package/test/fixtures/dependent-app.js +57 -0
- package/test/fixtures/dummy-app.js +69 -0
- package/test/fixtures/service-app.js +42 -0
- package/test/fixtures/singleton-app.js +35 -0
- package/test/port-allocator.test.js +76 -0
- package/test/rollbridge.test.js +444 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import fs from "node:fs/promises"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
import {fileURLToPath} from "node:url"
|
|
9
|
+
import {loadConfig} from "../src/config.js"
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
|
+
|
|
13
|
+
test("TensorBuzz example config loads", async () => {
|
|
14
|
+
const config = await loadConfig(path.join(repoRoot, "examples", "tensorbuzz.com.js"))
|
|
15
|
+
|
|
16
|
+
assert.equal(config.application, "tensorbuzz")
|
|
17
|
+
assert.equal(config.control.path, "/tmp/rollbridge-tensorbuzz.sock")
|
|
18
|
+
assert.equal(config.proxy.host, "127.0.0.1")
|
|
19
|
+
assert.equal(config.proxy.port, 4500)
|
|
20
|
+
assert.equal(config.proxy.healthPath, "/ping")
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
config.processes.map((processConfig) => [processConfig.id, processConfig.policy]),
|
|
23
|
+
[
|
|
24
|
+
["beacon", "service"],
|
|
25
|
+
["background-jobs-main", "service"],
|
|
26
|
+
["background-jobs-worker", "companion"],
|
|
27
|
+
["web", "proxied"]
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("loadConfig resolves a config module that exports a function", async () => {
|
|
34
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-fn-config-"))
|
|
35
|
+
const configPath = path.join(dir, "rollbridge.js")
|
|
36
|
+
const moduleSource = [
|
|
37
|
+
"module.exports = () => ({",
|
|
38
|
+
" application: process.env.ROLLBRIDGE_TEST_APP || \"fn-demo\",",
|
|
39
|
+
" control: {path: \"/tmp/rollbridge-fn-demo.sock\"},",
|
|
40
|
+
" proxy: {host: \"127.0.0.1\", port: 8190},",
|
|
41
|
+
" processes: [{id: \"web\", policy: \"proxied\", command: \"run web\", port: {from: 18000, to: 18099}}]",
|
|
42
|
+
"})",
|
|
43
|
+
""
|
|
44
|
+
].join("\n")
|
|
45
|
+
|
|
46
|
+
await fs.writeFile(configPath, moduleSource)
|
|
47
|
+
process.env.ROLLBRIDGE_TEST_APP = "computed-app"
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const config = await loadConfig(configPath)
|
|
51
|
+
|
|
52
|
+
assert.equal(config.application, "computed-app")
|
|
53
|
+
assert.equal(config.proxy.port, 8190)
|
|
54
|
+
assert.equal(config.processes[0].id, "web")
|
|
55
|
+
} finally {
|
|
56
|
+
delete process.env.ROLLBRIDGE_TEST_APP
|
|
57
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
58
|
+
}
|
|
59
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import fs from "node:fs/promises"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
import {resolveConfigPath} from "../src/config.js"
|
|
9
|
+
import {runCli} from "../src/cli.js"
|
|
10
|
+
|
|
11
|
+
const validConfig = {
|
|
12
|
+
application: "demo",
|
|
13
|
+
control: {path: "/tmp/rollbridge-config-path.sock"},
|
|
14
|
+
processes: [
|
|
15
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}
|
|
16
|
+
],
|
|
17
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} dir - Directory to write the module into.
|
|
22
|
+
* @returns {Promise<string>} The written config module path.
|
|
23
|
+
*/
|
|
24
|
+
async function writeConfigModule(dir) {
|
|
25
|
+
const configPath = path.join(dir, "rollbridge.js")
|
|
26
|
+
|
|
27
|
+
// CommonJS so the module loads from a temp dir (no package.json) on any supported Node version.
|
|
28
|
+
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(validConfig, null, 2)}\n`)
|
|
29
|
+
|
|
30
|
+
return configPath
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("resolveConfigPath returns an explicit path unchanged", async () => {
|
|
34
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
assert.equal(await resolveConfigPath("/somewhere/custom.js", dir), "/somewhere/custom.js")
|
|
38
|
+
} finally {
|
|
39
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("resolveConfigPath resolves rollbridge.js in the working directory", async () => {
|
|
44
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await writeConfigModule(dir)
|
|
48
|
+
|
|
49
|
+
assert.equal(await resolveConfigPath(undefined, dir), path.join(dir, "rollbridge.js"))
|
|
50
|
+
} finally {
|
|
51
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("resolveConfigPath throws an actionable error when no default config exists", async () => {
|
|
56
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await assert.rejects(
|
|
60
|
+
() => resolveConfigPath(undefined, dir),
|
|
61
|
+
/No config file found.*rollbridge\.js/
|
|
62
|
+
)
|
|
63
|
+
} finally {
|
|
64
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("validate CLI command resolves the default config when --config is omitted", async () => {
|
|
69
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
70
|
+
const originalCwd = process.cwd()
|
|
71
|
+
|
|
72
|
+
await writeConfigModule(dir)
|
|
73
|
+
|
|
74
|
+
const originalLog = console.log
|
|
75
|
+
/** @type {string[]} */
|
|
76
|
+
const lines = []
|
|
77
|
+
|
|
78
|
+
console.log = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
process.chdir(dir)
|
|
82
|
+
await runCli(["node", "rollbridge", "validate"])
|
|
83
|
+
} finally {
|
|
84
|
+
console.log = originalLog
|
|
85
|
+
process.chdir(originalCwd)
|
|
86
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assert.match(lines.join("\n"), /rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
|
|
90
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import fs from "node:fs/promises"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
import {normalizeConfig, validateConfig} from "../src/config.js"
|
|
9
|
+
import {runCli} from "../src/cli.js"
|
|
10
|
+
|
|
11
|
+
test("validateConfig collects duplicate ids, proxied ports, and policy combinations", () => {
|
|
12
|
+
const {issues} = validateConfig({
|
|
13
|
+
application: "demo",
|
|
14
|
+
control: {path: "/tmp/demo.sock"},
|
|
15
|
+
processes: [
|
|
16
|
+
{command: "run web", id: "web", policy: "proxied"},
|
|
17
|
+
{command: "run worker", id: "web", policy: "companion"}
|
|
18
|
+
],
|
|
19
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
20
|
+
})
|
|
21
|
+
const messages = issues.map((issue) => issue.message)
|
|
22
|
+
|
|
23
|
+
assert.ok(messages.includes("Duplicate process id: web"), `expected duplicate id issue in ${JSON.stringify(messages)}`)
|
|
24
|
+
assert.ok(messages.includes("Proxied process web must define a port range"), `expected missing proxied port issue in ${JSON.stringify(messages)}`)
|
|
25
|
+
assert.ok(issues.every((issue) => typeof issue.fix === "string" && issue.fix.length > 0), "every issue should include an example fix")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("validateConfig reports invalid ranges and missing proxied process without throwing", () => {
|
|
29
|
+
const {issues} = validateConfig({
|
|
30
|
+
application: "demo",
|
|
31
|
+
processes: [
|
|
32
|
+
{command: "run worker", id: "worker", policy: "companion", port: {from: 200, to: 100}}
|
|
33
|
+
],
|
|
34
|
+
proxy: {port: 8182}
|
|
35
|
+
})
|
|
36
|
+
const messages = issues.map((issue) => issue.message)
|
|
37
|
+
|
|
38
|
+
assert.ok(messages.includes("processes[0].port must be a positive port or valid {from, to} range"), `expected invalid range issue in ${JSON.stringify(messages)}`)
|
|
39
|
+
assert.ok(messages.includes("Config must define exactly one proxied process; found 0"), `expected missing proxied process issue in ${JSON.stringify(messages)}`)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("validateConfig returns a normalized config and no issues for a valid config", () => {
|
|
43
|
+
const {config, issues} = validateConfig({
|
|
44
|
+
application: "demo",
|
|
45
|
+
control: {path: "/tmp/demo.sock"},
|
|
46
|
+
processes: [
|
|
47
|
+
{command: "run web", health: {path: "/ping"}, id: "web", policy: "proxied", port: {from: 18000, to: 18099}}
|
|
48
|
+
],
|
|
49
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(issues, [])
|
|
53
|
+
assert.equal(config.processes.length, 1)
|
|
54
|
+
assert.equal(config.processes[0].policy, "proxied")
|
|
55
|
+
assert.equal(config.proxy.port, 8182)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("normalizeConfig throws an aggregated error listing every issue", () => {
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => normalizeConfig({
|
|
61
|
+
application: "demo",
|
|
62
|
+
processes: [
|
|
63
|
+
{command: "run web", id: "web", policy: "proxied"},
|
|
64
|
+
{command: "run web", id: "web", policy: "proxied"}
|
|
65
|
+
],
|
|
66
|
+
proxy: {port: 8182}
|
|
67
|
+
}),
|
|
68
|
+
(error) => {
|
|
69
|
+
assert.ok(error instanceof Error)
|
|
70
|
+
assert.match(error.message, /Duplicate process id: web/)
|
|
71
|
+
assert.match(error.message, /exactly one proxied process; found 2/)
|
|
72
|
+
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("validate CLI command reports every issue with a fix and exits non-zero", async () => {
|
|
79
|
+
const configPath = await writeConfig({
|
|
80
|
+
application: "demo",
|
|
81
|
+
processes: [
|
|
82
|
+
{command: "run web", id: "web", policy: "proxied"}
|
|
83
|
+
],
|
|
84
|
+
proxy: {port: 8182}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const {output} = await captureCli(["node", "rollbridge", "validate", "-c", configPath])
|
|
89
|
+
|
|
90
|
+
assert.equal(process.exitCode, 1)
|
|
91
|
+
assert.match(output, /Proxied process web must define a port range/)
|
|
92
|
+
assert.match(output, /Fix: Add a port range to the proxied process "web"/)
|
|
93
|
+
} finally {
|
|
94
|
+
process.exitCode = 0
|
|
95
|
+
await fs.rm(path.dirname(configPath), {force: true, recursive: true})
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("validate CLI command accepts a valid config without setting a failure exit code", async () => {
|
|
100
|
+
const configPath = await writeConfig({
|
|
101
|
+
application: "demo",
|
|
102
|
+
control: {path: "/tmp/rollbridge-cli-valid.sock"},
|
|
103
|
+
processes: [
|
|
104
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}
|
|
105
|
+
],
|
|
106
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const {output} = await captureCli(["node", "rollbridge", "validate", "-c", configPath])
|
|
111
|
+
|
|
112
|
+
assert.notEqual(process.exitCode, 1)
|
|
113
|
+
assert.match(output, /is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
|
|
114
|
+
} finally {
|
|
115
|
+
await fs.rm(path.dirname(configPath), {force: true, recursive: true})
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {Record<string, import("../src/json.js").JsonValue>} config - Raw config object.
|
|
121
|
+
* @returns {Promise<string>} Path to the written config module.
|
|
122
|
+
*/
|
|
123
|
+
async function writeConfig(config) {
|
|
124
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-validate-"))
|
|
125
|
+
const configPath = path.join(dir, "rollbridge.js")
|
|
126
|
+
|
|
127
|
+
// CommonJS so the module loads from a temp dir (no package.json) on any supported Node version.
|
|
128
|
+
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
129
|
+
|
|
130
|
+
return configPath
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Runs the CLI while capturing console output.
|
|
135
|
+
* @param {string[]} argv - Process argv.
|
|
136
|
+
* @returns {Promise<{output: string}>} Captured stdout and stderr lines joined by newlines.
|
|
137
|
+
*/
|
|
138
|
+
async function captureCli(argv) {
|
|
139
|
+
const originalLog = console.log
|
|
140
|
+
const originalError = console.error
|
|
141
|
+
/** @type {string[]} */
|
|
142
|
+
const lines = []
|
|
143
|
+
const collect = (/** @type {string[]} */ ...args) => { lines.push(args.map((arg) => String(arg)).join(" ")) }
|
|
144
|
+
|
|
145
|
+
console.log = collect
|
|
146
|
+
console.error = collect
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await runCli(argv)
|
|
150
|
+
} finally {
|
|
151
|
+
console.log = originalLog
|
|
152
|
+
console.error = originalError
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {output: lines.join("\n")}
|
|
156
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import http from "node:http"
|
|
4
|
+
|
|
5
|
+
const port = Number(requiredEnv("ROLLBRIDGE_PORT"))
|
|
6
|
+
const servicePort = Number(requiredEnv("ROLLBRIDGE_BEACON_PORT"))
|
|
7
|
+
const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
8
|
+
|
|
9
|
+
await waitForService()
|
|
10
|
+
|
|
11
|
+
const server = http.createServer((request, response) => {
|
|
12
|
+
if (request.url === "/ping") {
|
|
13
|
+
response.writeHead(200, {"Content-Type": "application/json"})
|
|
14
|
+
response.end(JSON.stringify({message: "Pong", releaseId}))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"})
|
|
19
|
+
response.end(`${releaseId}\n`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
process.on("SIGTERM", () => {
|
|
23
|
+
server.close(() => process.exit(0))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
server.listen(port, "127.0.0.1")
|
|
27
|
+
|
|
28
|
+
/** @returns {Promise<void>} Resolves when the service responds. */
|
|
29
|
+
async function waitForService() {
|
|
30
|
+
const deadline = Date.now() + 3000
|
|
31
|
+
|
|
32
|
+
while (Date.now() < deadline) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`http://127.0.0.1:${servicePort}/ping`)
|
|
35
|
+
|
|
36
|
+
if (response.ok) return
|
|
37
|
+
} catch (_error) {
|
|
38
|
+
// The dependency may still be binding its port.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new Error("Service did not become ready")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} key - Environment variable name.
|
|
49
|
+
* @returns {string} Environment variable value.
|
|
50
|
+
*/
|
|
51
|
+
function requiredEnv(key) {
|
|
52
|
+
const value = process.env[key]
|
|
53
|
+
|
|
54
|
+
if (!value) throw new Error(`${key} is required`)
|
|
55
|
+
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto"
|
|
4
|
+
import http from "node:http"
|
|
5
|
+
|
|
6
|
+
const port = Number(process.env.ROLLBRIDGE_PORT)
|
|
7
|
+
const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
8
|
+
const healthFails = releaseId.includes("bad")
|
|
9
|
+
const sockets = new Set()
|
|
10
|
+
|
|
11
|
+
const server = http.createServer((request, response) => {
|
|
12
|
+
if (request.url === "/ping") {
|
|
13
|
+
if (healthFails) {
|
|
14
|
+
response.writeHead(500, {"Content-Type": "application/json"})
|
|
15
|
+
response.end(JSON.stringify({message: "bad release"}))
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
response.writeHead(200, {"Content-Type": "application/json"})
|
|
20
|
+
response.end(JSON.stringify({message: "Pong", releaseId}))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (request.url === "/release") {
|
|
25
|
+
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"})
|
|
26
|
+
response.end(`${releaseId}\n`)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8"})
|
|
31
|
+
response.end("Not found\n")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
server.on("upgrade", (request, socket) => {
|
|
35
|
+
const key = request.headers["sec-websocket-key"]
|
|
36
|
+
|
|
37
|
+
if (typeof key !== "string") {
|
|
38
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const accept = crypto
|
|
43
|
+
.createHash("sha1")
|
|
44
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
45
|
+
.digest("base64")
|
|
46
|
+
|
|
47
|
+
sockets.add(socket)
|
|
48
|
+
socket.once("close", () => sockets.delete(socket))
|
|
49
|
+
socket.on("data", () => {
|
|
50
|
+
socket.end()
|
|
51
|
+
})
|
|
52
|
+
socket.write([
|
|
53
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
54
|
+
"Upgrade: websocket",
|
|
55
|
+
"Connection: Upgrade",
|
|
56
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
57
|
+
"\r\n"
|
|
58
|
+
].join("\r\n"))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
process.on("SIGTERM", () => {
|
|
62
|
+
server.close(() => process.exit(0))
|
|
63
|
+
|
|
64
|
+
if (sockets.size === 0) {
|
|
65
|
+
setTimeout(() => process.exit(0), 10)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
server.listen(port, "127.0.0.1")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
import http from "node:http"
|
|
5
|
+
|
|
6
|
+
const logPath = requiredEnv("ROLLBRIDGE_SERVICE_LOG")
|
|
7
|
+
const port = Number(requiredEnv("ROLLBRIDGE_PORT"))
|
|
8
|
+
const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
9
|
+
|
|
10
|
+
writeEvent("start")
|
|
11
|
+
|
|
12
|
+
const server = http.createServer((_request, response) => {
|
|
13
|
+
response.writeHead(200, {"Content-Type": "application/json"})
|
|
14
|
+
response.end(JSON.stringify({releaseId}))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
process.on("SIGTERM", () => {
|
|
18
|
+
writeEvent("stop")
|
|
19
|
+
server.close(() => process.exit(0))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
server.listen(port, "127.0.0.1")
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {"start" | "stop"} event - Event.
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
function writeEvent(event) {
|
|
29
|
+
fs.appendFileSync(logPath, `${JSON.stringify({event, pid: process.pid, releaseId})}\n`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} key - Environment variable name.
|
|
34
|
+
* @returns {string} Environment variable value.
|
|
35
|
+
*/
|
|
36
|
+
function requiredEnv(key) {
|
|
37
|
+
const value = process.env[key]
|
|
38
|
+
|
|
39
|
+
if (!value) throw new Error(`${key} is required`)
|
|
40
|
+
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
|
|
5
|
+
const logPath = requiredEnv("ROLLBRIDGE_SINGLETON_LOG")
|
|
6
|
+
const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
7
|
+
|
|
8
|
+
writeEvent("start")
|
|
9
|
+
|
|
10
|
+
process.on("SIGTERM", () => {
|
|
11
|
+
writeEvent("stop")
|
|
12
|
+
setTimeout(() => process.exit(0), 50)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
setInterval(() => {}, 1000)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {"start" | "stop"} event - Event.
|
|
19
|
+
* @returns {void}
|
|
20
|
+
*/
|
|
21
|
+
function writeEvent(event) {
|
|
22
|
+
fs.appendFileSync(logPath, `${JSON.stringify({event, pid: process.pid, releaseId})}\n`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} key - Environment variable name.
|
|
27
|
+
* @returns {string} Environment variable value.
|
|
28
|
+
*/
|
|
29
|
+
function requiredEnv(key) {
|
|
30
|
+
const value = process.env[key]
|
|
31
|
+
|
|
32
|
+
if (!value) throw new Error(`${key} is required`)
|
|
33
|
+
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
import test from "node:test"
|
|
6
|
+
import {findAvailablePort} from "../src/port-allocator.js"
|
|
7
|
+
|
|
8
|
+
const host = "127.0.0.1"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Binds a server to an ephemeral port so that port is occupied for the test.
|
|
12
|
+
* @returns {Promise<{port: number, server: import("node:net").Server}>} Occupied port and its server.
|
|
13
|
+
*/
|
|
14
|
+
async function occupyPort() {
|
|
15
|
+
const server = net.createServer()
|
|
16
|
+
const port = await new Promise((resolve, reject) => {
|
|
17
|
+
server.once("error", reject)
|
|
18
|
+
server.listen(0, host, () => {
|
|
19
|
+
const address = server.address()
|
|
20
|
+
|
|
21
|
+
resolve(address && typeof address === "object" ? address.port : 0)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return {port, server}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import("node:net").Server} server - Server to close.
|
|
30
|
+
* @returns {Promise<void>} Resolves once closed.
|
|
31
|
+
*/
|
|
32
|
+
async function closeServer(server) {
|
|
33
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("findAvailablePort reports reserved and in-use counts when a range is exhausted", async () => {
|
|
37
|
+
const {port, server} = await occupyPort()
|
|
38
|
+
// Use the occupied port as the upper bound so the range stays within valid TCP bounds.
|
|
39
|
+
const reservedPort = port - 1
|
|
40
|
+
const range = {from: reservedPort, to: port}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await assert.rejects(
|
|
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/)
|
|
51
|
+
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
} finally {
|
|
56
|
+
await closeServer(server)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("findAvailablePort skips the occupied port and records the allocated one", async () => {
|
|
61
|
+
const {port, server} = await occupyPort()
|
|
62
|
+
const usedPorts = /** @type {Set<number>} */ (new Set())
|
|
63
|
+
// Keep the upper bound at or below 65535 while still including the occupied port.
|
|
64
|
+
const from = Math.min(port, 65515)
|
|
65
|
+
const to = from + 20
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const allocated = await findAvailablePort({host, range: {from, to}, usedPorts})
|
|
69
|
+
|
|
70
|
+
assert.notEqual(allocated, port)
|
|
71
|
+
assert.ok(allocated >= from && allocated <= to)
|
|
72
|
+
assert.ok(usedPorts.has(allocated))
|
|
73
|
+
} finally {
|
|
74
|
+
await closeServer(server)
|
|
75
|
+
}
|
|
76
|
+
})
|