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
package/src/cli.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
import fsPromises from "node:fs/promises"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import {spawn} from "node:child_process"
|
|
7
|
+
import {Command} from "commander"
|
|
8
|
+
import RollbridgeDaemon from "./daemon.js"
|
|
9
|
+
import {loadConfig, parseConfigFile, resolveConfigPath, validateConfig} from "./config.js"
|
|
10
|
+
import {sendControlCommand} from "./control-client.js"
|
|
11
|
+
|
|
12
|
+
const DEFAULT_DAEMON_START_TIMEOUT_MS = 10000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs the CLI.
|
|
16
|
+
* @param {string[]} argv - Process argv.
|
|
17
|
+
* @returns {Promise<void>} Resolves when complete.
|
|
18
|
+
*/
|
|
19
|
+
export async function runCli(argv) {
|
|
20
|
+
const program = new Command()
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name("rollbridge")
|
|
24
|
+
.description("Zero-downtime process supervisor and local traffic switcher.")
|
|
25
|
+
.showHelpAfterError()
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command("daemon")
|
|
29
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
const configPath = await resolveConfigPath(options.config)
|
|
32
|
+
const config = await loadConfig(configPath)
|
|
33
|
+
const daemon = new RollbridgeDaemon({config})
|
|
34
|
+
|
|
35
|
+
await daemon.start()
|
|
36
|
+
|
|
37
|
+
const shutdown = async () => {
|
|
38
|
+
await daemon.shutdown()
|
|
39
|
+
process.exit(0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.once("SIGINT", () => { void shutdown() })
|
|
43
|
+
process.once("SIGTERM", () => { void shutdown() })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("deploy")
|
|
48
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
49
|
+
.requiredOption("--release-path <path>", "Release path")
|
|
50
|
+
.option("--release-id <id>", "Release id")
|
|
51
|
+
.option("--revision <sha>", "Revision")
|
|
52
|
+
.option("--ensure-daemon", "Start the Rollbridge daemon if it is not already running")
|
|
53
|
+
.option("--daemon-log-path <path>", "Log path used when --ensure-daemon starts the daemon")
|
|
54
|
+
.option("--daemon-pid-path <path>", "PID file path used when --ensure-daemon starts the daemon")
|
|
55
|
+
.option("--daemon-start-timeout-ms <ms>", "How long to wait for an ensured daemon to accept control commands")
|
|
56
|
+
.action(async (options) => {
|
|
57
|
+
const configPath = await resolveConfigPath(options.config)
|
|
58
|
+
const config = await loadConfig(configPath)
|
|
59
|
+
|
|
60
|
+
if (options.ensureDaemon) {
|
|
61
|
+
await ensureDaemonRunning({
|
|
62
|
+
argv,
|
|
63
|
+
config,
|
|
64
|
+
configPath,
|
|
65
|
+
logPath: options.daemonLogPath,
|
|
66
|
+
pidPath: options.daemonPidPath,
|
|
67
|
+
timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = await sendControlCommand({
|
|
72
|
+
command: {
|
|
73
|
+
command: "deploy",
|
|
74
|
+
releaseId: options.releaseId,
|
|
75
|
+
releasePath: options.releasePath,
|
|
76
|
+
revision: options.revision
|
|
77
|
+
},
|
|
78
|
+
path: config.control.path
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
console.log(JSON.stringify(response, null, 2))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
program
|
|
85
|
+
.command("ensure-daemon")
|
|
86
|
+
.description("Start the daemon if the control socket is not already accepting commands.")
|
|
87
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
88
|
+
.option("--daemon-log-path <path>", "Daemon log path")
|
|
89
|
+
.option("--daemon-pid-path <path>", "Daemon PID file path")
|
|
90
|
+
.option("--daemon-start-timeout-ms <ms>", "How long to wait for the daemon to accept control commands")
|
|
91
|
+
.action(async (options) => {
|
|
92
|
+
const configPath = await resolveConfigPath(options.config)
|
|
93
|
+
const config = await loadConfig(configPath)
|
|
94
|
+
const response = await ensureDaemonRunning({
|
|
95
|
+
argv,
|
|
96
|
+
config,
|
|
97
|
+
configPath,
|
|
98
|
+
logPath: options.daemonLogPath,
|
|
99
|
+
pidPath: options.daemonPidPath,
|
|
100
|
+
timeoutMs: normalizeTimeoutMs(options.daemonStartTimeoutMs)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
console.log(JSON.stringify(response, null, 2))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
program
|
|
107
|
+
.command("status")
|
|
108
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
109
|
+
.action(async (options) => {
|
|
110
|
+
const configPath = await resolveConfigPath(options.config)
|
|
111
|
+
const config = await loadConfig(configPath)
|
|
112
|
+
const response = await sendControlCommand({
|
|
113
|
+
command: {command: "status"},
|
|
114
|
+
path: config.control.path
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
console.log(JSON.stringify(response, null, 2))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
program
|
|
121
|
+
.command("stop")
|
|
122
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
123
|
+
.option("--release-id <id>", "Release id")
|
|
124
|
+
.action(async (options) => {
|
|
125
|
+
const configPath = await resolveConfigPath(options.config)
|
|
126
|
+
const config = await loadConfig(configPath)
|
|
127
|
+
const response = await sendControlCommand({
|
|
128
|
+
command: {
|
|
129
|
+
command: "stop",
|
|
130
|
+
releaseId: options.releaseId
|
|
131
|
+
},
|
|
132
|
+
path: config.control.path
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
console.log(JSON.stringify(response, null, 2))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
program
|
|
139
|
+
.command("shutdown")
|
|
140
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
141
|
+
.action(async (options) => {
|
|
142
|
+
const configPath = await resolveConfigPath(options.config)
|
|
143
|
+
const config = await loadConfig(configPath)
|
|
144
|
+
const response = await sendControlCommand({
|
|
145
|
+
command: {command: "shutdown"},
|
|
146
|
+
path: config.control.path
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
console.log(JSON.stringify(response, null, 2))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
program
|
|
153
|
+
.command("validate")
|
|
154
|
+
.description("Parse the config and report all errors without starting the daemon.")
|
|
155
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
156
|
+
.action(async (options) => {
|
|
157
|
+
let configPath
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
configPath = await resolveConfigPath(options.config)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
163
|
+
process.exitCode = 1
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const {config, issues} = await validateConfigFile(configPath)
|
|
168
|
+
|
|
169
|
+
if (issues.length === 0) {
|
|
170
|
+
const processCount = config.processes.length
|
|
171
|
+
|
|
172
|
+
console.log(`${configPath} is valid: ${processCount} ${processCount === 1 ? "process" : "processes"}, proxy on ${config.proxy.host}:${config.proxy.port}.`)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.error(`Found ${issues.length} configuration ${issues.length === 1 ? "issue" : "issues"} in ${configPath}:`)
|
|
177
|
+
|
|
178
|
+
issues.forEach((issue, index) => {
|
|
179
|
+
console.error(`\n${index + 1}. ${issue.message}`)
|
|
180
|
+
console.error(` Fix: ${issue.fix}`)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
process.exitCode = 1
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await program.parseAsync(argv)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reads, parses, and validates a config file, collecting read, parse, and validation issues.
|
|
191
|
+
* @param {string} configPath - Config file path.
|
|
192
|
+
* @returns {Promise<{config: import("./config.js").RollbridgeConfig, issues: import("./config.js").ConfigIssue[]}>} Best-effort config and any issues.
|
|
193
|
+
*/
|
|
194
|
+
async function validateConfigFile(configPath) {
|
|
195
|
+
try {
|
|
196
|
+
const {absolutePath, rawConfig} = await parseConfigFile(configPath)
|
|
197
|
+
|
|
198
|
+
return validateConfig(rawConfig, absolutePath)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const {config} = validateConfig({}, configPath)
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
202
|
+
|
|
203
|
+
return {config, issues: [{fix: "Ensure the file exists and exports a default Rollbridge config object.", message}]}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Starts a daemon when needed and waits until it accepts status commands.
|
|
209
|
+
* @param {object} args - Options.
|
|
210
|
+
* @param {string[]} args.argv - Original CLI argv.
|
|
211
|
+
* @param {import("./config.js").RollbridgeConfig} args.config - Loaded config.
|
|
212
|
+
* @param {string} args.configPath - Config path.
|
|
213
|
+
* @param {string | undefined} args.logPath - Optional daemon log path.
|
|
214
|
+
* @param {string | undefined} args.pidPath - Optional daemon PID path.
|
|
215
|
+
* @param {number} args.timeoutMs - Startup timeout.
|
|
216
|
+
* @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
|
|
217
|
+
*/
|
|
218
|
+
async function ensureDaemonRunning({argv, config, configPath, logPath, pidPath, timeoutMs}) {
|
|
219
|
+
const existingStatus = await daemonStatus(config)
|
|
220
|
+
|
|
221
|
+
if (existingStatus) return existingStatus
|
|
222
|
+
|
|
223
|
+
await startDaemonProcess({
|
|
224
|
+
argv,
|
|
225
|
+
configPath,
|
|
226
|
+
logPath: logPath || defaultDaemonLogPath(config),
|
|
227
|
+
pidPath: pidPath || defaultDaemonPidPath(config)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return await waitForDaemonStatus(config, timeoutMs)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {import("./config.js").RollbridgeConfig} config - Loaded config.
|
|
235
|
+
* @returns {Promise<Record<string, import("./json.js").JsonValue> | undefined>} Status when the daemon responds.
|
|
236
|
+
*/
|
|
237
|
+
async function daemonStatus(config) {
|
|
238
|
+
try {
|
|
239
|
+
return await sendControlCommand({
|
|
240
|
+
command: {command: "status"},
|
|
241
|
+
path: config.control.path
|
|
242
|
+
})
|
|
243
|
+
} catch (error) {
|
|
244
|
+
const errorWithCode = error && typeof error === "object"
|
|
245
|
+
? /** @type {{code?: string}} */ (error)
|
|
246
|
+
: undefined
|
|
247
|
+
|
|
248
|
+
if (isMissingDaemonError(errorWithCode)) return undefined
|
|
249
|
+
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Starts the foreground daemon command as a detached child.
|
|
256
|
+
* @param {object} args - Options.
|
|
257
|
+
* @param {string[]} args.argv - Original CLI argv.
|
|
258
|
+
* @param {string} args.configPath - Config path.
|
|
259
|
+
* @param {string} args.logPath - Log file path.
|
|
260
|
+
* @param {string} args.pidPath - PID file path.
|
|
261
|
+
* @returns {Promise<void>} Resolves after the child has been spawned.
|
|
262
|
+
*/
|
|
263
|
+
async function startDaemonProcess({argv, configPath, logPath, pidPath}) {
|
|
264
|
+
const binPath = argv[1] || process.argv[1]
|
|
265
|
+
|
|
266
|
+
if (!binPath) throw new Error("Unable to determine Rollbridge CLI path for daemon startup")
|
|
267
|
+
|
|
268
|
+
await fsPromises.mkdir(path.dirname(logPath), {recursive: true})
|
|
269
|
+
await fsPromises.mkdir(path.dirname(pidPath), {recursive: true})
|
|
270
|
+
|
|
271
|
+
const stdoutFd = fs.openSync(logPath, "a")
|
|
272
|
+
const stderrFd = fs.openSync(logPath, "a")
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const child = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {
|
|
276
|
+
detached: true,
|
|
277
|
+
env: process.env,
|
|
278
|
+
stdio: ["ignore", stdoutFd, stderrFd]
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
child.unref()
|
|
282
|
+
|
|
283
|
+
if (child.pid) {
|
|
284
|
+
await fsPromises.writeFile(pidPath, `${child.pid}\n`)
|
|
285
|
+
}
|
|
286
|
+
} finally {
|
|
287
|
+
fs.closeSync(stdoutFd)
|
|
288
|
+
fs.closeSync(stderrFd)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Waits until a daemon answers status commands.
|
|
294
|
+
* @param {import("./config.js").RollbridgeConfig} config - Loaded config.
|
|
295
|
+
* @param {number} timeoutMs - Timeout in milliseconds.
|
|
296
|
+
* @returns {Promise<Record<string, import("./json.js").JsonValue>>} Daemon status response.
|
|
297
|
+
*/
|
|
298
|
+
async function waitForDaemonStatus(config, timeoutMs) {
|
|
299
|
+
const deadline = Date.now() + timeoutMs
|
|
300
|
+
let lastError = /** @type {Error | undefined} */ (undefined)
|
|
301
|
+
|
|
302
|
+
while (Date.now() < deadline) {
|
|
303
|
+
try {
|
|
304
|
+
const status = await daemonStatus(config)
|
|
305
|
+
|
|
306
|
+
if (status) return status
|
|
307
|
+
} catch (error) {
|
|
308
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const detail = lastError ? ` Last error: ${lastError.message}` : ""
|
|
315
|
+
|
|
316
|
+
throw new Error(`Rollbridge daemon did not become ready within ${timeoutMs}ms.${detail}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {import("./config.js").RollbridgeConfig} config - Loaded config.
|
|
321
|
+
* @returns {string} Default daemon log path.
|
|
322
|
+
*/
|
|
323
|
+
function defaultDaemonLogPath(config) {
|
|
324
|
+
return `/tmp/rollbridge-${config.application}.log`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {import("./config.js").RollbridgeConfig} config - Loaded config.
|
|
329
|
+
* @returns {string} Default daemon PID path.
|
|
330
|
+
*/
|
|
331
|
+
function defaultDaemonPidPath(config) {
|
|
332
|
+
return `/tmp/rollbridge-${config.application}.pid`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @param {string | undefined} value - Raw timeout value.
|
|
337
|
+
* @returns {number} Timeout in milliseconds.
|
|
338
|
+
*/
|
|
339
|
+
function normalizeTimeoutMs(value) {
|
|
340
|
+
if (value === undefined) return DEFAULT_DAEMON_START_TIMEOUT_MS
|
|
341
|
+
|
|
342
|
+
const timeoutMs = Number(value)
|
|
343
|
+
|
|
344
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
|
|
345
|
+
throw new Error("--daemon-start-timeout-ms must be a positive number")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return timeoutMs
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* @param {{code?: string} | Error | null | undefined} error - Error value.
|
|
353
|
+
* @returns {boolean} True when the error means no daemon is accepting commands.
|
|
354
|
+
*/
|
|
355
|
+
function isMissingDaemonError(error) {
|
|
356
|
+
if (!error || typeof error !== "object" || !("code" in error)) return false
|
|
357
|
+
|
|
358
|
+
return error.code === "ENOENT" || error.code === "ECONNREFUSED"
|
|
359
|
+
}
|