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/daemon.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import http from "node:http"
|
|
5
|
+
import net from "node:net"
|
|
6
|
+
import httpProxy from "http-proxy"
|
|
7
|
+
import ReleaseGroup from "./release-group.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
11
|
+
* @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
|
|
12
|
+
* @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
|
|
13
|
+
* @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, proxy: {host: string, port: number | undefined}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export default class RollbridgeDaemon {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} args - Options.
|
|
19
|
+
* @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
|
|
20
|
+
* @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
|
|
21
|
+
*/
|
|
22
|
+
constructor({config, logger}) {
|
|
23
|
+
this.config = config
|
|
24
|
+
this.logger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
|
|
25
|
+
this.releases = /** @type {Map<string, ReleaseGroup>} */ (new Map())
|
|
26
|
+
this.services = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
|
|
27
|
+
this.servicePorts = /** @type {Record<string, number>} */ ({})
|
|
28
|
+
this.singletons = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
|
|
29
|
+
this.activeRelease = /** @type {ReleaseGroup | undefined} */ (undefined)
|
|
30
|
+
this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
|
|
31
|
+
this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
|
|
32
|
+
this.controlServer = /** @type {net.Server | undefined} */ (undefined)
|
|
33
|
+
this.proxyPort = /** @type {number | undefined} */ (undefined)
|
|
34
|
+
this.stopping = false
|
|
35
|
+
|
|
36
|
+
this.proxy.on("error", (error, req, res) => this.onProxyError(error, req, res))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @returns {Promise<void>} Starts proxy and control listeners. */
|
|
40
|
+
async start() {
|
|
41
|
+
await this.startProxy()
|
|
42
|
+
await this.startControlServer()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns {Promise<void>} Starts the stable local proxy. */
|
|
46
|
+
async startProxy() {
|
|
47
|
+
const server = http.createServer((request, response) => this.proxyHttp(request, response))
|
|
48
|
+
|
|
49
|
+
server.on("upgrade", (request, socket, head) => this.proxyWebSocket(request, socket, head))
|
|
50
|
+
this.proxyServer = server
|
|
51
|
+
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
server.once("error", reject)
|
|
54
|
+
server.listen(this.config.proxy.port, this.config.proxy.host, () => {
|
|
55
|
+
const address = server.address()
|
|
56
|
+
this.proxyPort = address && typeof address === "object" ? address.port : this.config.proxy.port
|
|
57
|
+
this.logger("proxy listening", {host: this.config.proxy.host, port: this.proxyPort})
|
|
58
|
+
resolve(undefined)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns {Promise<void>} Starts the control socket. */
|
|
64
|
+
async startControlServer() {
|
|
65
|
+
const server = net.createServer((socket) => this.handleControlSocket(socket))
|
|
66
|
+
|
|
67
|
+
this.controlServer = server
|
|
68
|
+
await this.prepareControlSocketPath()
|
|
69
|
+
|
|
70
|
+
await new Promise((resolve, reject) => {
|
|
71
|
+
server.once("error", reject)
|
|
72
|
+
server.listen(this.config.control.path, () => {
|
|
73
|
+
this.logger("control socket listening", {path: this.config.control.path})
|
|
74
|
+
resolve(undefined)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @returns {Promise<void>} Removes a stale Unix socket before binding, or fails clearly when a daemon is alive. */
|
|
80
|
+
async prepareControlSocketPath() {
|
|
81
|
+
const existing = await inspectControlSocket(this.config.control.path)
|
|
82
|
+
|
|
83
|
+
if (existing.alive) {
|
|
84
|
+
throw new Error(controlSocketBusyMessage(this.config.control.path, existing))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {http.IncomingMessage} request - Client request.
|
|
97
|
+
* @param {http.ServerResponse} response - Client response.
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
proxyHttp(request, response) {
|
|
101
|
+
const release = this.activeRelease
|
|
102
|
+
|
|
103
|
+
if (!release) {
|
|
104
|
+
response.writeHead(503, {"Content-Type": "text/plain; charset=utf-8"})
|
|
105
|
+
response.end("No active release\n")
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const {target} = release.proxyTarget()
|
|
110
|
+
const releaseConnection = release.retainConnection("http")
|
|
111
|
+
let released = false
|
|
112
|
+
const done = () => {
|
|
113
|
+
if (released) return
|
|
114
|
+
|
|
115
|
+
released = true
|
|
116
|
+
releaseConnection()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
response.once("finish", done)
|
|
120
|
+
response.once("close", done)
|
|
121
|
+
this.proxy.web(request, response, {target})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {http.IncomingMessage} request - Client request.
|
|
126
|
+
* @param {import("node:stream").Duplex} socket - Client socket.
|
|
127
|
+
* @param {Buffer} head - Upgrade head.
|
|
128
|
+
* @returns {void}
|
|
129
|
+
*/
|
|
130
|
+
proxyWebSocket(request, socket, head) {
|
|
131
|
+
const release = this.activeRelease
|
|
132
|
+
|
|
133
|
+
if (!release) {
|
|
134
|
+
socket.end("HTTP/1.1 503 Service Unavailable\r\n\r\n")
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const {target} = release.proxyTarget()
|
|
139
|
+
const releaseConnection = release.retainConnection("websocket")
|
|
140
|
+
socket.once("close", releaseConnection)
|
|
141
|
+
this.proxy.ws(request, socket, head, {target})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {Error} error - Proxy error.
|
|
146
|
+
* @param {http.IncomingMessage} _request - Client request.
|
|
147
|
+
* @param {http.ServerResponse | import("node:net").Socket} response - Response or socket.
|
|
148
|
+
* @returns {void}
|
|
149
|
+
*/
|
|
150
|
+
onProxyError(error, _request, response) {
|
|
151
|
+
this.logger("proxy error", {error: error.message})
|
|
152
|
+
|
|
153
|
+
if ("writeHead" in response && !response.headersSent) {
|
|
154
|
+
response.writeHead(502, {"Content-Type": "text/plain; charset=utf-8"})
|
|
155
|
+
response.end("Bad gateway\n")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if ("destroy" in response) {
|
|
160
|
+
response.destroy()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {import("node:net").Socket} socket - Control socket.
|
|
166
|
+
* @returns {void}
|
|
167
|
+
*/
|
|
168
|
+
handleControlSocket(socket) {
|
|
169
|
+
socket.setEncoding("utf8")
|
|
170
|
+
let buffer = ""
|
|
171
|
+
|
|
172
|
+
socket.on("data", (chunk) => {
|
|
173
|
+
buffer += chunk
|
|
174
|
+
let newlineIndex = buffer.indexOf("\n")
|
|
175
|
+
|
|
176
|
+
while (newlineIndex >= 0) {
|
|
177
|
+
const line = buffer.slice(0, newlineIndex)
|
|
178
|
+
buffer = buffer.slice(newlineIndex + 1)
|
|
179
|
+
this.handleControlLine(line, socket)
|
|
180
|
+
newlineIndex = buffer.indexOf("\n")
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {string} line - JSON command line.
|
|
187
|
+
* @param {import("node:net").Socket} socket - Control socket.
|
|
188
|
+
* @returns {void}
|
|
189
|
+
*/
|
|
190
|
+
handleControlLine(line, socket) {
|
|
191
|
+
this.executeControlLine(line)
|
|
192
|
+
.then((response) => socket.write(`${JSON.stringify({status: "success", ...response})}\n`))
|
|
193
|
+
.catch((error) => {
|
|
194
|
+
socket.write(`${JSON.stringify({
|
|
195
|
+
error: error instanceof Error ? error.message : String(error),
|
|
196
|
+
status: "error"
|
|
197
|
+
})}\n`)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} line - JSON command line.
|
|
203
|
+
* @returns {Promise<Record<string, JsonValue>>} Command response.
|
|
204
|
+
*/
|
|
205
|
+
async executeControlLine(line) {
|
|
206
|
+
const command = JSON.parse(line)
|
|
207
|
+
|
|
208
|
+
if (!command || typeof command !== "object") {
|
|
209
|
+
throw new Error("Control command must be an object")
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const data = /** @type {Record<string, JsonValue>} */ (command)
|
|
213
|
+
const commandName = data.command
|
|
214
|
+
|
|
215
|
+
if (commandName === "deploy") {
|
|
216
|
+
return await this.deploy({
|
|
217
|
+
releaseId: stringOrUndefined(data.releaseId),
|
|
218
|
+
releasePath: requiredString(data.releasePath, "releasePath"),
|
|
219
|
+
revision: stringOrUndefined(data.revision)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (commandName === "status") {
|
|
224
|
+
return this.status()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (commandName === "stop") {
|
|
228
|
+
await this.stopRelease(stringOrUndefined(data.releaseId))
|
|
229
|
+
return this.status()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (commandName === "shutdown") {
|
|
233
|
+
setImmediate(() => {
|
|
234
|
+
this.shutdown().catch((error) => {
|
|
235
|
+
this.logger("shutdown failed", {error: error instanceof Error ? error.message : String(error)})
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return {message: "shutdown"}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
throw new Error(`Unknown command: ${String(commandName)}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Starts a new release, switches traffic, and drains the previous release.
|
|
247
|
+
* @param {DeployArgs} args - Deploy args.
|
|
248
|
+
* @returns {Promise<Record<string, JsonValue>>} Deploy result.
|
|
249
|
+
*/
|
|
250
|
+
async deploy({releaseId, releasePath, revision}) {
|
|
251
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
252
|
+
|
|
253
|
+
const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
|
|
254
|
+
const release = new ReleaseGroup({
|
|
255
|
+
config: this.config,
|
|
256
|
+
logger: this.logger,
|
|
257
|
+
releaseId: newReleaseId,
|
|
258
|
+
releasePath,
|
|
259
|
+
revision,
|
|
260
|
+
servicePorts: this.servicePorts
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
|
|
264
|
+
const startedServices = /** @type {string[]} */ ([])
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await this.ensureServices(release, startedServices)
|
|
268
|
+
await release.start()
|
|
269
|
+
} catch (error) {
|
|
270
|
+
await this.stopStartedServices(startedServices)
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const previousRelease = this.activeRelease
|
|
275
|
+
|
|
276
|
+
this.releases.set(release.releaseId, release)
|
|
277
|
+
release.activate()
|
|
278
|
+
this.activeRelease = release
|
|
279
|
+
this.logger("traffic switched", {previousReleaseId: previousRelease ? previousRelease.releaseId : null, releaseId: release.releaseId})
|
|
280
|
+
|
|
281
|
+
this.refreshServiceDefinitions(release)
|
|
282
|
+
await this.replaceSingletons(release)
|
|
283
|
+
|
|
284
|
+
if (previousRelease) {
|
|
285
|
+
previousRelease.drainAndStop(this.config.proxy.drainTimeoutMs).catch((error) => {
|
|
286
|
+
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: previousRelease.releaseId})
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
activeReleaseId: release.releaseId,
|
|
292
|
+
previousReleaseId: previousRelease ? previousRelease.releaseId : null
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Starts missing daemon-wide services before release-owned processes need them.
|
|
298
|
+
* @param {ReleaseGroup} release - Release providing templates and ports.
|
|
299
|
+
* @param {string[]} startedServices - Service ids started by this deploy.
|
|
300
|
+
* @returns {Promise<void>} Resolves when missing services are running.
|
|
301
|
+
*/
|
|
302
|
+
async ensureServices(release, startedServices) {
|
|
303
|
+
await release.allocatePorts()
|
|
304
|
+
|
|
305
|
+
for (const processConfig of this.config.processes) {
|
|
306
|
+
if (processConfig.policy !== "service") continue
|
|
307
|
+
if (this.services.has(processConfig.id)) continue
|
|
308
|
+
|
|
309
|
+
const service = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
|
|
310
|
+
|
|
311
|
+
this.services.set(processConfig.id, service)
|
|
312
|
+
|
|
313
|
+
if (release.ports[processConfig.id] !== undefined) {
|
|
314
|
+
this.servicePorts[processConfig.id] = release.ports[processConfig.id]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await service.start()
|
|
319
|
+
startedServices.push(processConfig.id)
|
|
320
|
+
} catch (error) {
|
|
321
|
+
this.services.delete(processConfig.id)
|
|
322
|
+
delete this.servicePorts[processConfig.id]
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Stops services that were started for a failed deploy.
|
|
330
|
+
* @param {string[]} startedServices - Service ids started by the failed deploy.
|
|
331
|
+
* @returns {Promise<void>} Resolves when cleanup finishes.
|
|
332
|
+
*/
|
|
333
|
+
async stopStartedServices(startedServices) {
|
|
334
|
+
for (const serviceId of startedServices) {
|
|
335
|
+
const service = this.services.get(serviceId)
|
|
336
|
+
|
|
337
|
+
if (!service) continue
|
|
338
|
+
|
|
339
|
+
await service.stop()
|
|
340
|
+
this.services.delete(serviceId)
|
|
341
|
+
delete this.servicePorts[serviceId]
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Updates daemon-wide service restart templates after a successful deploy.
|
|
347
|
+
* @param {ReleaseGroup} release - Active release.
|
|
348
|
+
* @returns {void}
|
|
349
|
+
*/
|
|
350
|
+
refreshServiceDefinitions(release) {
|
|
351
|
+
for (const processConfig of this.config.processes) {
|
|
352
|
+
if (processConfig.policy !== "service") continue
|
|
353
|
+
|
|
354
|
+
const service = this.services.get(processConfig.id)
|
|
355
|
+
|
|
356
|
+
if (!service) continue
|
|
357
|
+
|
|
358
|
+
const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
|
|
359
|
+
|
|
360
|
+
service.updateDefinition({
|
|
361
|
+
command: nextDefinition.command,
|
|
362
|
+
cwd: nextDefinition.cwd,
|
|
363
|
+
env: nextDefinition.env,
|
|
364
|
+
logger: nextDefinition.logger,
|
|
365
|
+
restartDelayMs: nextDefinition.restartDelayMs,
|
|
366
|
+
shouldRestart: nextDefinition.shouldRestart,
|
|
367
|
+
stopTimeoutMs: nextDefinition.stopTimeoutMs
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Restarts singleton processes for the new release without overlapping old singleton processes.
|
|
374
|
+
* @param {ReleaseGroup} release - Active release.
|
|
375
|
+
* @returns {Promise<void>} Resolves when singletons have been replaced.
|
|
376
|
+
*/
|
|
377
|
+
async replaceSingletons(release) {
|
|
378
|
+
for (const processConfig of this.config.processes) {
|
|
379
|
+
if (processConfig.policy !== "singleton") continue
|
|
380
|
+
|
|
381
|
+
const previous = this.singletons.get(processConfig.id)
|
|
382
|
+
|
|
383
|
+
if (previous) {
|
|
384
|
+
await previous.stop()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const singleton = release.buildProcess(processConfig)
|
|
388
|
+
|
|
389
|
+
this.singletons.set(processConfig.id, singleton)
|
|
390
|
+
await singleton.start()
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {string | undefined} releaseId - Release id, or active release when omitted.
|
|
396
|
+
* @returns {Promise<void>} Resolves when stopped.
|
|
397
|
+
*/
|
|
398
|
+
async stopRelease(releaseId) {
|
|
399
|
+
const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
|
|
400
|
+
|
|
401
|
+
if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
|
|
402
|
+
if (release === this.activeRelease) this.activeRelease = undefined
|
|
403
|
+
|
|
404
|
+
await release.stop()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** @returns {Promise<void>} Stops proxy, control socket, and child processes. */
|
|
408
|
+
async shutdown() {
|
|
409
|
+
if (this.stopping) return
|
|
410
|
+
|
|
411
|
+
this.stopping = true
|
|
412
|
+
this.proxy.close()
|
|
413
|
+
await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
|
|
414
|
+
await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
|
|
415
|
+
await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
|
|
416
|
+
await this.closeServer(this.proxyServer)
|
|
417
|
+
await this.closeServer(this.controlServer)
|
|
418
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {net.Server | http.Server | undefined} server - Server.
|
|
423
|
+
* @returns {Promise<void>} Resolves when closed.
|
|
424
|
+
*/
|
|
425
|
+
async closeServer(server) {
|
|
426
|
+
if (!server || !server.listening) return
|
|
427
|
+
|
|
428
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** @returns {number | undefined} Current proxy port. */
|
|
432
|
+
getProxyPort() {
|
|
433
|
+
return this.proxyPort
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** @returns {DaemonStatus} Status payload. */
|
|
437
|
+
status() {
|
|
438
|
+
return {
|
|
439
|
+
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
440
|
+
application: this.config.application,
|
|
441
|
+
control: {...this.config.control},
|
|
442
|
+
proxy: {
|
|
443
|
+
host: this.config.proxy.host,
|
|
444
|
+
port: this.proxyPort ?? this.config.proxy.port
|
|
445
|
+
},
|
|
446
|
+
releases: [...this.releases.values()].map((release) => release.status()),
|
|
447
|
+
services: [...this.services.entries()].map(([id, processInstance]) => ({
|
|
448
|
+
id,
|
|
449
|
+
process: processInstance.status()
|
|
450
|
+
})),
|
|
451
|
+
singletons: [...this.singletons.entries()].map(([id, processInstance]) => ({
|
|
452
|
+
id,
|
|
453
|
+
process: processInstance.status()
|
|
454
|
+
}))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* @param {JsonValue} value - Value.
|
|
461
|
+
* @returns {string | undefined} String value.
|
|
462
|
+
*/
|
|
463
|
+
function stringOrUndefined(value) {
|
|
464
|
+
if (value === undefined || value === null) return undefined
|
|
465
|
+
if (typeof value !== "string") throw new Error("Expected string value")
|
|
466
|
+
|
|
467
|
+
return value
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {JsonValue} value - Value.
|
|
472
|
+
* @param {string} key - Key.
|
|
473
|
+
* @returns {string} String value.
|
|
474
|
+
*/
|
|
475
|
+
function requiredString(value, key) {
|
|
476
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
477
|
+
throw new Error(`${key} is required`)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return value
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null}} ControlSocketInspection
|
|
485
|
+
*/
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Builds an operator-facing message explaining why the control socket cannot be bound.
|
|
489
|
+
* @param {string} socketPath - Control socket path.
|
|
490
|
+
* @param {ControlSocketInspection} inspection - Result of probing the socket.
|
|
491
|
+
* @returns {string} Diagnostic message.
|
|
492
|
+
*/
|
|
493
|
+
function controlSocketBusyMessage(socketPath, inspection) {
|
|
494
|
+
if (inspection.application === undefined) {
|
|
495
|
+
return `The control socket ${socketPath} is already in use by another process. Stop that process or set a different control.path.`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const releaseDetail = inspection.activeReleaseId ? `active release: ${inspection.activeReleaseId}` : "no active release"
|
|
499
|
+
|
|
500
|
+
return `A Rollbridge daemon for application "${inspection.application}" is already running on ${socketPath} (${releaseDetail}). ` +
|
|
501
|
+
`Run "rollbridge status" to inspect it or "rollbridge shutdown" to stop it, or set a different control.path.`
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Probes an existing control socket to see whether a daemon is alive, and identifies it when it is Rollbridge.
|
|
506
|
+
* @param {string} socketPath - Control socket path.
|
|
507
|
+
* @param {number} [timeoutMs] - How long to wait for a status response before treating the socket as busy.
|
|
508
|
+
* @returns {Promise<ControlSocketInspection>} Whether the socket is live and, when it is Rollbridge, its identity.
|
|
509
|
+
*/
|
|
510
|
+
async function inspectControlSocket(socketPath, timeoutMs = 1000) {
|
|
511
|
+
return await new Promise((resolve, reject) => {
|
|
512
|
+
const socket = net.createConnection(socketPath)
|
|
513
|
+
let buffer = ""
|
|
514
|
+
let settled = false
|
|
515
|
+
let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
|
|
516
|
+
|
|
517
|
+
const finish = (/** @type {ControlSocketInspection} */ result) => {
|
|
518
|
+
if (settled) return
|
|
519
|
+
|
|
520
|
+
settled = true
|
|
521
|
+
if (timer) clearTimeout(timer)
|
|
522
|
+
socket.destroy()
|
|
523
|
+
resolve(result)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
timer = setTimeout(() => finish({alive: true}), timeoutMs)
|
|
527
|
+
socket.setEncoding("utf8")
|
|
528
|
+
socket.once("connect", () => socket.write(`${JSON.stringify({command: "status"})}\n`))
|
|
529
|
+
socket.on("data", (chunk) => {
|
|
530
|
+
buffer += chunk
|
|
531
|
+
const newlineIndex = buffer.indexOf("\n")
|
|
532
|
+
|
|
533
|
+
if (newlineIndex < 0) return
|
|
534
|
+
|
|
535
|
+
const status = parseControlStatus(buffer.slice(0, newlineIndex))
|
|
536
|
+
|
|
537
|
+
finish(status ? {activeReleaseId: status.activeReleaseId, alive: true, application: status.application} : {alive: true})
|
|
538
|
+
})
|
|
539
|
+
socket.once("error", (error) => {
|
|
540
|
+
if (settled) return
|
|
541
|
+
|
|
542
|
+
if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ECONNREFUSED")) {
|
|
543
|
+
settled = true
|
|
544
|
+
if (timer) clearTimeout(timer)
|
|
545
|
+
resolve({alive: false})
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
settled = true
|
|
550
|
+
if (timer) clearTimeout(timer)
|
|
551
|
+
reject(error)
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Parses a control status response line into a Rollbridge identity, if it is one.
|
|
558
|
+
* @param {string} line - JSON response line.
|
|
559
|
+
* @returns {{application: string, activeReleaseId: string | null} | undefined} Identity, or undefined when unrecognized.
|
|
560
|
+
*/
|
|
561
|
+
function parseControlStatus(line) {
|
|
562
|
+
/** @type {JsonValue} */
|
|
563
|
+
let parsed
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
parsed = JSON.parse(line)
|
|
567
|
+
} catch {
|
|
568
|
+
return undefined
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
|
|
572
|
+
if (typeof parsed.application !== "string") return undefined
|
|
573
|
+
|
|
574
|
+
return {activeReleaseId: typeof parsed.activeReleaseId === "string" ? parsed.activeReleaseId : null, application: parsed.application}
|
|
575
|
+
}
|
package/src/health.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Waits for a process HTTP health endpoint.
|
|
5
|
+
* @param {object} args - Options.
|
|
6
|
+
* @param {string} args.host - Host.
|
|
7
|
+
* @param {number} args.port - Port.
|
|
8
|
+
* @param {import("./config.js").HealthConfig} args.health - Health config.
|
|
9
|
+
* @returns {Promise<void>} Resolves when healthy.
|
|
10
|
+
*/
|
|
11
|
+
export async function waitForHealth({health, host, port}) {
|
|
12
|
+
const deadline = Date.now() + health.timeoutMs
|
|
13
|
+
const url = `http://${host}:${port}${health.path}`
|
|
14
|
+
let lastError = "no attempts"
|
|
15
|
+
|
|
16
|
+
while (Date.now() <= deadline) {
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(url)
|
|
19
|
+
|
|
20
|
+
if (response.ok) return
|
|
21
|
+
|
|
22
|
+
lastError = `HTTP ${response.status}`
|
|
23
|
+
} catch (error) {
|
|
24
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, health.intervalMs))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(`Health check failed for ${url}: ${lastError}`)
|
|
31
|
+
}
|
package/src/json.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// JSON-compatible value parsed from config files or assembled for logs and control responses.
|
|
2
|
+
// `undefined` is included so optional and partially-built fields stay representable without `unknown`.
|
|
3
|
+
export type JsonValue = boolean | number | string | null | undefined | JsonValue[] | {[key: string]: JsonValue}
|