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,232 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {EventEmitter} from "node:events"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
8
|
+
* @typedef {"starting" | "running" | "stopping" | "stopped" | "failed"} ManagedProcessState
|
|
9
|
+
* @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
|
|
10
|
+
* @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
|
|
11
|
+
* @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, restartDelayMs: number, shouldRestart: () => boolean, stopTimeoutMs: number}} ManagedProcessDefinition
|
|
12
|
+
* @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, state: ManagedProcessState}} ManagedProcessStatus
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export default class ManagedProcess extends EventEmitter {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} args - Options.
|
|
18
|
+
* @param {string} args.command - Shell command.
|
|
19
|
+
* @param {string | undefined} args.cwd - Working directory.
|
|
20
|
+
* @param {Record<string, string | undefined>} args.env - Environment.
|
|
21
|
+
* @param {string} args.id - Process id.
|
|
22
|
+
* @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger callback.
|
|
23
|
+
* @param {number} args.restartDelayMs - Restart delay.
|
|
24
|
+
* @param {() => boolean} args.shouldRestart - Restart policy callback.
|
|
25
|
+
* @param {number} args.stopTimeoutMs - Stop timeout.
|
|
26
|
+
*/
|
|
27
|
+
constructor({command, cwd, env, id, logger, restartDelayMs, shouldRestart, stopTimeoutMs}) {
|
|
28
|
+
super()
|
|
29
|
+
|
|
30
|
+
this.command = command
|
|
31
|
+
this.cwd = cwd
|
|
32
|
+
this.env = env
|
|
33
|
+
this.id = id
|
|
34
|
+
this.logger = logger
|
|
35
|
+
this.restartDelayMs = restartDelayMs
|
|
36
|
+
this.shouldRestart = shouldRestart
|
|
37
|
+
this.stopTimeoutMs = stopTimeoutMs
|
|
38
|
+
this.state = /** @type {ManagedProcessState} */ ("stopped")
|
|
39
|
+
this.logs = /** @type {ManagedProcessLog[]} */ ([])
|
|
40
|
+
this.intentionalStop = false
|
|
41
|
+
this.restartTimer = undefined
|
|
42
|
+
this.child = undefined
|
|
43
|
+
this.exitPromise = undefined
|
|
44
|
+
this.pid = undefined
|
|
45
|
+
this.exitCode = undefined
|
|
46
|
+
this.exitSignal = undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {Promise<void>} Resolves after spawn. */
|
|
50
|
+
async start() {
|
|
51
|
+
if (this.child) return
|
|
52
|
+
|
|
53
|
+
this.intentionalStop = false
|
|
54
|
+
this.exitCode = undefined
|
|
55
|
+
this.exitSignal = undefined
|
|
56
|
+
this.state = "starting"
|
|
57
|
+
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
const child = spawn(this.command, {
|
|
60
|
+
cwd: this.cwd,
|
|
61
|
+
detached: true,
|
|
62
|
+
env: {...process.env, ...this.env},
|
|
63
|
+
shell: true,
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
this.child = child
|
|
68
|
+
this.pid = child.pid
|
|
69
|
+
this.exitPromise = new Promise((exitResolve) => {
|
|
70
|
+
child.once("exit", (code, signal) => {
|
|
71
|
+
this.onExit(code, signal)
|
|
72
|
+
exitResolve(undefined)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
child.once("spawn", () => {
|
|
77
|
+
this.state = "running"
|
|
78
|
+
this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
|
|
79
|
+
this.emit("started")
|
|
80
|
+
resolve(undefined)
|
|
81
|
+
})
|
|
82
|
+
child.once("error", (error) => {
|
|
83
|
+
this.state = "failed"
|
|
84
|
+
reject(error)
|
|
85
|
+
})
|
|
86
|
+
child.stdout.setEncoding("utf8")
|
|
87
|
+
child.stderr.setEncoding("utf8")
|
|
88
|
+
child.stdout.on("data", (chunk) => this.appendLog("stdout", chunk))
|
|
89
|
+
child.stderr.on("data", (chunk) => this.appendLog("stderr", chunk))
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Updates the command template used for future restarts without touching the currently running child.
|
|
95
|
+
* @param {ManagedProcessDefinition} definition - Replacement process definition.
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
updateDefinition(definition) {
|
|
99
|
+
this.command = definition.command
|
|
100
|
+
this.cwd = definition.cwd
|
|
101
|
+
this.env = definition.env
|
|
102
|
+
this.logger = definition.logger
|
|
103
|
+
this.restartDelayMs = definition.restartDelayMs
|
|
104
|
+
this.shouldRestart = definition.shouldRestart
|
|
105
|
+
this.stopTimeoutMs = definition.stopTimeoutMs
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {"stdout" | "stderr"} stream - Stream name.
|
|
110
|
+
* @param {string} chunk - Output chunk.
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
appendLog(stream, chunk) {
|
|
114
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
115
|
+
if (!line) continue
|
|
116
|
+
|
|
117
|
+
this.logs.push({at: new Date().toISOString(), line, stream})
|
|
118
|
+
|
|
119
|
+
if (this.logs.length > 200) {
|
|
120
|
+
this.logs.splice(0, this.logs.length - 200)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {number | null} code - Exit code.
|
|
127
|
+
* @param {ProcessExitSignal} signal - Exit signal.
|
|
128
|
+
* @returns {void}
|
|
129
|
+
*/
|
|
130
|
+
onExit(code, signal) {
|
|
131
|
+
const wasIntentional = this.intentionalStop
|
|
132
|
+
|
|
133
|
+
this.exitCode = code
|
|
134
|
+
this.exitSignal = signal
|
|
135
|
+
this.child = undefined
|
|
136
|
+
this.pid = undefined
|
|
137
|
+
this.exitPromise = undefined
|
|
138
|
+
this.state = wasIntentional ? "stopped" : "failed"
|
|
139
|
+
this.logger("process exited", {code, id: this.id, signal})
|
|
140
|
+
this.emit("exit", {code, signal})
|
|
141
|
+
|
|
142
|
+
if (!wasIntentional && this.shouldRestart()) {
|
|
143
|
+
this.restartTimer = setTimeout(() => {
|
|
144
|
+
this.restartTimer = undefined
|
|
145
|
+
this.start().catch((error) => {
|
|
146
|
+
this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
|
|
147
|
+
})
|
|
148
|
+
}, this.restartDelayMs)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {{timeoutMs?: number}} [options] - Stop options.
|
|
154
|
+
* @returns {Promise<void>} Resolves when stopped.
|
|
155
|
+
*/
|
|
156
|
+
async stop(options = {}) {
|
|
157
|
+
this.intentionalStop = true
|
|
158
|
+
|
|
159
|
+
if (this.restartTimer) {
|
|
160
|
+
clearTimeout(this.restartTimer)
|
|
161
|
+
this.restartTimer = undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const child = this.child
|
|
165
|
+
|
|
166
|
+
if (!child || !child.pid) {
|
|
167
|
+
this.state = "stopped"
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.state = "stopping"
|
|
172
|
+
this.killProcessGroup("SIGTERM")
|
|
173
|
+
const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
|
|
174
|
+
const stopped = await this.waitForExit(timeoutMs)
|
|
175
|
+
|
|
176
|
+
if (!stopped) {
|
|
177
|
+
this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: child.pid})
|
|
178
|
+
this.killProcessGroup("SIGKILL")
|
|
179
|
+
await this.waitForExit(5000)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.state = "stopped"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {"SIGTERM" | "SIGKILL"} signal - Signal to send.
|
|
187
|
+
* @returns {void}
|
|
188
|
+
*/
|
|
189
|
+
killProcessGroup(signal) {
|
|
190
|
+
if (!this.child || !this.child.pid) return
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
process.kill(-this.child.pid, signal)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
|
|
196
|
+
throw error
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {number} timeoutMs - Timeout.
|
|
202
|
+
* @returns {Promise<boolean>} True when the process exited before timeout.
|
|
203
|
+
*/
|
|
204
|
+
async waitForExit(timeoutMs) {
|
|
205
|
+
if (!this.exitPromise) return true
|
|
206
|
+
|
|
207
|
+
let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
|
|
208
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
209
|
+
timer = setTimeout(() => resolve(false), timeoutMs)
|
|
210
|
+
})
|
|
211
|
+
const exitPromise = this.exitPromise.then(() => true)
|
|
212
|
+
const result = await Promise.race([exitPromise, timeoutPromise])
|
|
213
|
+
|
|
214
|
+
if (timer) clearTimeout(timer)
|
|
215
|
+
|
|
216
|
+
return Boolean(result)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @returns {ManagedProcessStatus} Status payload. */
|
|
220
|
+
status() {
|
|
221
|
+
return {
|
|
222
|
+
command: this.command,
|
|
223
|
+
cwd: this.cwd,
|
|
224
|
+
exitCode: this.exitCode,
|
|
225
|
+
exitSignal: this.exitSignal,
|
|
226
|
+
id: this.id,
|
|
227
|
+
logs: this.logs.slice(-20),
|
|
228
|
+
pid: this.pid,
|
|
229
|
+
state: this.state
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import net from "node:net"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{from: number, to: number}} PortRange
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tests whether a port can be bound.
|
|
11
|
+
* @param {object} args - Options.
|
|
12
|
+
* @param {string} args.host - Bind host.
|
|
13
|
+
* @param {number} args.port - Candidate port.
|
|
14
|
+
* @returns {Promise<{port: number} | {code: string}>} The bound port, or the bind error code.
|
|
15
|
+
*/
|
|
16
|
+
async function tryPort({host, port}) {
|
|
17
|
+
const server = net.createServer()
|
|
18
|
+
|
|
19
|
+
return await new Promise((resolve) => {
|
|
20
|
+
server.once("error", (error) => {
|
|
21
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "EUNKNOWN"
|
|
22
|
+
|
|
23
|
+
resolve({code})
|
|
24
|
+
})
|
|
25
|
+
server.listen(port, host, () => {
|
|
26
|
+
const address = server.address()
|
|
27
|
+
const boundPort = address && typeof address === "object" ? address.port : port
|
|
28
|
+
|
|
29
|
+
server.close(() => resolve({port: boundPort}))
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Finds an available port in a range.
|
|
36
|
+
* @param {object} args - Options.
|
|
37
|
+
* @param {string} args.host - Bind host.
|
|
38
|
+
* @param {PortRange | undefined} args.range - Candidate range.
|
|
39
|
+
* @param {Set<number>} args.usedPorts - Ports already allocated by this deploy.
|
|
40
|
+
* @returns {Promise<number>} Available port.
|
|
41
|
+
*/
|
|
42
|
+
export async function findAvailablePort({host, range, usedPorts}) {
|
|
43
|
+
if (!range || range.from === 0 || range.to === 0) {
|
|
44
|
+
const result = await tryPort({host, port: 0})
|
|
45
|
+
|
|
46
|
+
if (!("port" in result)) throw new Error(`Unable to allocate an ephemeral port on ${host} (${result.code})`)
|
|
47
|
+
usedPorts.add(result.port)
|
|
48
|
+
|
|
49
|
+
return result.port
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let reserved = 0
|
|
53
|
+
let inUse = 0
|
|
54
|
+
let unavailable = 0
|
|
55
|
+
/** @type {string | undefined} */
|
|
56
|
+
let lastErrorCode
|
|
57
|
+
|
|
58
|
+
for (let port = range.from; port <= range.to; port += 1) {
|
|
59
|
+
if (usedPorts.has(port)) {
|
|
60
|
+
reserved += 1
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await tryPort({host, port})
|
|
65
|
+
|
|
66
|
+
if ("port" in result) {
|
|
67
|
+
usedPorts.add(result.port)
|
|
68
|
+
return result.port
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (result.code === "EADDRINUSE") {
|
|
72
|
+
inUse += 1
|
|
73
|
+
} else {
|
|
74
|
+
unavailable += 1
|
|
75
|
+
lastErrorCode = result.code
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const total = range.to - range.from + 1
|
|
80
|
+
const details = [`${reserved} reserved by this deploy`, `${inUse} already in use`]
|
|
81
|
+
|
|
82
|
+
if (unavailable > 0) details.push(`${unavailable} could not be bound (e.g. ${lastErrorCode})`)
|
|
83
|
+
|
|
84
|
+
throw new Error(
|
|
85
|
+
`No available ports in range ${range.from}-${range.to} (${total} port${total === 1 ? "" : "s"} on ${host}): ` +
|
|
86
|
+
`${details.join(", ")}. Widen the port range, free a port, or check bind permissions.`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {EventEmitter} from "node:events"
|
|
4
|
+
import ManagedProcess from "./managed-process.js"
|
|
5
|
+
import {findAvailablePort} from "./port-allocator.js"
|
|
6
|
+
import {renderObject, renderTemplate} from "./template.js"
|
|
7
|
+
import {waitForHealth} from "./health.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
11
|
+
* @typedef {"starting" | "active" | "draining" | "stopped" | "failed"} ReleaseState
|
|
12
|
+
* @typedef {{http: number, websocket: number}} ReleaseConnections
|
|
13
|
+
* @typedef {{activatedAt: string | undefined, connectionCount: number, connections: ReleaseConnections, drainStartedAt: string | undefined, ports: Record<string, number>, processes: import("./managed-process.js").ManagedProcessStatus[], releaseId: string, releasePath: string, revision: string, state: ReleaseState, stoppedAt: string | undefined}} ReleaseStatus
|
|
14
|
+
* @typedef {{shouldRestart?: () => boolean}} BuildProcessOptions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} id - Process id.
|
|
19
|
+
* @returns {string} Environment suffix.
|
|
20
|
+
*/
|
|
21
|
+
function envId(id) {
|
|
22
|
+
return id.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default class ReleaseGroup extends EventEmitter {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} args - Options.
|
|
28
|
+
* @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
|
|
29
|
+
* @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger.
|
|
30
|
+
* @param {string} args.releaseId - Release id.
|
|
31
|
+
* @param {string} args.releasePath - Release path.
|
|
32
|
+
* @param {string | undefined} args.revision - Revision.
|
|
33
|
+
* @param {Record<string, number>} [args.servicePorts] - Ports already owned by daemon-wide services.
|
|
34
|
+
*/
|
|
35
|
+
constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}}) {
|
|
36
|
+
super()
|
|
37
|
+
|
|
38
|
+
this.config = config
|
|
39
|
+
this.logger = logger
|
|
40
|
+
this.releaseId = releaseId
|
|
41
|
+
this.releasePath = releasePath
|
|
42
|
+
this.revision = revision || releaseId
|
|
43
|
+
this.state = /** @type {ReleaseState} */ ("starting")
|
|
44
|
+
this.connectionCount = 0
|
|
45
|
+
this.connections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
|
|
46
|
+
this.processes = /** @type {Map<string, ManagedProcess>} */ (new Map())
|
|
47
|
+
this.ports = /** @type {Record<string, number>} */ ({})
|
|
48
|
+
this.servicePorts = servicePorts
|
|
49
|
+
this.portsAllocated = false
|
|
50
|
+
this.drainStartedAt = /** @type {string | undefined} */ (undefined)
|
|
51
|
+
this.activatedAt = /** @type {string | undefined} */ (undefined)
|
|
52
|
+
this.stoppedAt = /** @type {string | undefined} */ (undefined)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @returns {Promise<void>} Starts release-owned processes and health checks the proxied process. */
|
|
56
|
+
async start() {
|
|
57
|
+
this.state = "starting"
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await this.allocatePorts()
|
|
61
|
+
|
|
62
|
+
for (const processConfig of this.releaseProcessStartOrder()) {
|
|
63
|
+
const processInstance = this.buildProcess(processConfig)
|
|
64
|
+
this.processes.set(processConfig.id, processInstance)
|
|
65
|
+
await processInstance.start()
|
|
66
|
+
|
|
67
|
+
if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
|
|
68
|
+
await waitForHealth({
|
|
69
|
+
health: processConfig.health,
|
|
70
|
+
host: this.config.proxy.host,
|
|
71
|
+
port: this.ports[processConfig.id]
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.state = "failed"
|
|
77
|
+
await this.stop()
|
|
78
|
+
throw error
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Starts companions before the proxied process so release-local dependencies are available before health checks.
|
|
84
|
+
* @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
|
|
85
|
+
*/
|
|
86
|
+
releaseProcessStartOrder() {
|
|
87
|
+
const releaseProcesses = this.config.processes.filter((processConfig) => !["singleton", "service"].includes(processConfig.policy))
|
|
88
|
+
const companionProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "companion")
|
|
89
|
+
const proxiedProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "proxied")
|
|
90
|
+
|
|
91
|
+
return [...companionProcesses, ...proxiedProcesses]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @returns {void} Marks this release active. */
|
|
95
|
+
activate() {
|
|
96
|
+
this.state = "active"
|
|
97
|
+
this.activatedAt = new Date().toISOString()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @returns {Promise<void>} Allocates all configured per-process ports. */
|
|
101
|
+
async allocatePorts() {
|
|
102
|
+
if (this.portsAllocated) return
|
|
103
|
+
|
|
104
|
+
const usedPorts = /** @type {Set<number>} */ (new Set())
|
|
105
|
+
|
|
106
|
+
for (const processConfig of this.config.processes) {
|
|
107
|
+
if (!processConfig.port) continue
|
|
108
|
+
if (processConfig.policy === "service" && this.servicePorts[processConfig.id] !== undefined) {
|
|
109
|
+
this.ports[processConfig.id] = this.servicePorts[processConfig.id]
|
|
110
|
+
usedPorts.add(this.servicePorts[processConfig.id])
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.ports[processConfig.id] = await findAvailablePort({
|
|
115
|
+
host: this.config.proxy.host,
|
|
116
|
+
range: processConfig.port,
|
|
117
|
+
usedPorts
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.portsAllocated = true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Builds a managed process from config.
|
|
126
|
+
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
127
|
+
* @param {BuildProcessOptions} [options] - Build options.
|
|
128
|
+
* @returns {ManagedProcess} Managed process.
|
|
129
|
+
*/
|
|
130
|
+
buildProcess(processConfig, options = {}) {
|
|
131
|
+
const context = this.contextForProcess(processConfig)
|
|
132
|
+
const renderedEnv = /** @type {Record<string, string>} */ (renderObject(processConfig.env, context))
|
|
133
|
+
const processEnv = {
|
|
134
|
+
...this.baseEnvironment(processConfig),
|
|
135
|
+
...renderedEnv
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return new ManagedProcess({
|
|
139
|
+
command: renderTemplate(processConfig.command, context),
|
|
140
|
+
cwd: processConfig.cwd ? renderTemplate(processConfig.cwd, context) : this.releasePath,
|
|
141
|
+
env: processEnv,
|
|
142
|
+
id: processConfig.id,
|
|
143
|
+
logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
|
|
144
|
+
restartDelayMs: processConfig.restartDelayMs,
|
|
145
|
+
shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
|
|
146
|
+
stopTimeoutMs: processConfig.gracefulStopMs
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
152
|
+
* @returns {Record<string, string>} Base environment.
|
|
153
|
+
*/
|
|
154
|
+
baseEnvironment(processConfig) {
|
|
155
|
+
/** @type {Record<string, string>} */
|
|
156
|
+
const env = {
|
|
157
|
+
ROLLBRIDGE_APPLICATION: this.config.application,
|
|
158
|
+
ROLLBRIDGE_PROCESS_ID: processConfig.id,
|
|
159
|
+
ROLLBRIDGE_RELEASE_ID: this.releaseId,
|
|
160
|
+
ROLLBRIDGE_RELEASE_PATH: this.releasePath,
|
|
161
|
+
ROLLBRIDGE_REVISION: this.revision
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (this.ports[processConfig.id] !== undefined) {
|
|
165
|
+
env.ROLLBRIDGE_PORT = String(this.ports[processConfig.id])
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [processId, port] of Object.entries(this.ports)) {
|
|
169
|
+
env[`ROLLBRIDGE_${envId(processId)}_PORT`] = String(port)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return env
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
177
|
+
* @returns {Record<string, JsonValue>} Template context.
|
|
178
|
+
*/
|
|
179
|
+
contextForProcess(processConfig) {
|
|
180
|
+
return {
|
|
181
|
+
application: this.config.application,
|
|
182
|
+
port: this.ports[processConfig.id],
|
|
183
|
+
ports: this.ports,
|
|
184
|
+
processId: processConfig.id,
|
|
185
|
+
proxy: {
|
|
186
|
+
host: this.config.proxy.host,
|
|
187
|
+
port: this.config.proxy.port
|
|
188
|
+
},
|
|
189
|
+
releaseId: this.releaseId,
|
|
190
|
+
releasePath: this.releasePath,
|
|
191
|
+
revision: this.revision
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @returns {{process: ManagedProcess, target: string}} Proxied process target.
|
|
197
|
+
*/
|
|
198
|
+
proxyTarget() {
|
|
199
|
+
const processConfig = this.config.processes.find((candidate) => candidate.policy === "proxied")
|
|
200
|
+
|
|
201
|
+
if (!processConfig) throw new Error("No proxied process configured")
|
|
202
|
+
|
|
203
|
+
const processInstance = this.processes.get(processConfig.id)
|
|
204
|
+
const port = this.ports[processConfig.id]
|
|
205
|
+
|
|
206
|
+
if (!processInstance || !port) {
|
|
207
|
+
throw new Error(`Proxied process ${processConfig.id} is not running`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
process: processInstance,
|
|
212
|
+
target: `http://${this.config.proxy.host}:${port}`
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {"http" | "websocket"} type - Connection type.
|
|
218
|
+
* @returns {() => void} Release callback.
|
|
219
|
+
*/
|
|
220
|
+
retainConnection(type) {
|
|
221
|
+
this.connectionCount += 1
|
|
222
|
+
this.connections[type] += 1
|
|
223
|
+
let released = false
|
|
224
|
+
|
|
225
|
+
return () => {
|
|
226
|
+
if (released) return
|
|
227
|
+
|
|
228
|
+
released = true
|
|
229
|
+
this.connectionCount -= 1
|
|
230
|
+
this.connections[type] -= 1
|
|
231
|
+
|
|
232
|
+
if (this.connectionCount === 0) {
|
|
233
|
+
this.emit("drained")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Starts draining and stops once existing connections close or timeout.
|
|
240
|
+
* @param {number} timeoutMs - Drain timeout.
|
|
241
|
+
* @returns {Promise<void>} Resolves when stopped.
|
|
242
|
+
*/
|
|
243
|
+
async drainAndStop(timeoutMs) {
|
|
244
|
+
if (this.state === "stopped") return
|
|
245
|
+
|
|
246
|
+
this.state = "draining"
|
|
247
|
+
this.drainStartedAt = new Date().toISOString()
|
|
248
|
+
|
|
249
|
+
if (this.connectionCount > 0) {
|
|
250
|
+
await new Promise((resolve) => {
|
|
251
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
252
|
+
this.once("drained", () => {
|
|
253
|
+
clearTimeout(timer)
|
|
254
|
+
resolve(undefined)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await this.stop()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @returns {Promise<void>} Stops all release-owned processes. */
|
|
263
|
+
async stop() {
|
|
264
|
+
const stopTasks = [...this.processes.values()].map((processInstance) => processInstance.stop())
|
|
265
|
+
|
|
266
|
+
await Promise.allSettled(stopTasks)
|
|
267
|
+
this.state = "stopped"
|
|
268
|
+
this.stoppedAt = new Date().toISOString()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @returns {ReleaseStatus} Status payload. */
|
|
272
|
+
status() {
|
|
273
|
+
return {
|
|
274
|
+
activatedAt: this.activatedAt,
|
|
275
|
+
connectionCount: this.connectionCount,
|
|
276
|
+
connections: {...this.connections},
|
|
277
|
+
drainStartedAt: this.drainStartedAt,
|
|
278
|
+
ports: {...this.ports},
|
|
279
|
+
processes: [...this.processes.values()].map((processInstance) => processInstance.status()),
|
|
280
|
+
releaseId: this.releaseId,
|
|
281
|
+
releasePath: this.releasePath,
|
|
282
|
+
revision: this.revision,
|
|
283
|
+
state: this.state,
|
|
284
|
+
stoppedAt: this.stoppedAt
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
5
|
+
* @typedef {Record<string, JsonValue>} TemplateContext
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves a dotted template key against the context.
|
|
10
|
+
* @param {string} key - Dotted key from a template expression.
|
|
11
|
+
* @param {TemplateContext} context - Template context.
|
|
12
|
+
* @returns {JsonValue} Resolved value.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveTemplateValue(key, context) {
|
|
15
|
+
const parts = key.split(".")
|
|
16
|
+
let current = /** @type {JsonValue} */ (context)
|
|
17
|
+
|
|
18
|
+
for (const part of parts) {
|
|
19
|
+
if (!current || typeof current !== "object") {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
current = /** @type {Record<string, JsonValue>} */ (current)[part]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return current
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders `{{key}}` placeholders in a string.
|
|
31
|
+
* @param {string} value - Template string.
|
|
32
|
+
* @param {TemplateContext} context - Template context.
|
|
33
|
+
* @returns {string} Rendered string.
|
|
34
|
+
*/
|
|
35
|
+
export function renderTemplate(value, context) {
|
|
36
|
+
return value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => {
|
|
37
|
+
const resolved = resolveTemplateValue(key, context)
|
|
38
|
+
|
|
39
|
+
if (resolved === undefined || resolved === null) {
|
|
40
|
+
throw new Error(`Missing template value for ${match}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return String(resolved)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Renders all string values in a plain JSON-like object.
|
|
49
|
+
* @param {JsonValue} value - Value to render.
|
|
50
|
+
* @param {TemplateContext} context - Template context.
|
|
51
|
+
* @returns {JsonValue} Rendered value.
|
|
52
|
+
*/
|
|
53
|
+
export function renderObject(value, context) {
|
|
54
|
+
if (typeof value === "string") {
|
|
55
|
+
return renderTemplate(value, context)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value.map((entry) => renderObject(entry, context))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (value && typeof value === "object") {
|
|
63
|
+
/** @type {Record<string, JsonValue>} */
|
|
64
|
+
const rendered = {}
|
|
65
|
+
|
|
66
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
67
|
+
rendered[key] = renderObject(entry, context)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return rendered
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return value
|
|
74
|
+
}
|
package/tensorbuzz.yml
ADDED