rollbridge 0.1.4 → 0.1.6

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.
@@ -3,7 +3,7 @@
3
3
  import {EventEmitter} from "node:events"
4
4
  import ManagedProcess from "./managed-process.js"
5
5
  import {findAvailablePort} from "./port-allocator.js"
6
- import {renderObject, renderTemplate} from "./template.js"
6
+ import {processTemplateContext, renderObject, renderTemplate} from "./template.js"
7
7
  import {waitForHealth} from "./health.js"
8
8
 
9
9
  /**
@@ -11,7 +11,7 @@ import {waitForHealth} from "./health.js"
11
11
  * @typedef {"starting" | "active" | "draining" | "stopped" | "failed"} ReleaseState
12
12
  * @typedef {{http: number, websocket: number}} ReleaseConnections
13
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
14
+ * @typedef {{count?: number, index?: number, instanceId?: string, shouldRestart?: () => boolean}} BuildProcessOptions
15
15
  */
16
16
 
17
17
  /**
@@ -22,6 +22,15 @@ function envId(id) {
22
22
  return id.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()
23
23
  }
24
24
 
25
+ /**
26
+ * @param {import("./config.js").ProcessConfig} processConfig - Process config.
27
+ * @param {number} index - Zero-based replica index.
28
+ * @returns {string} The instance id: the bare process id for a single replica, or `id#index`.
29
+ */
30
+ function replicaInstanceId(processConfig, index) {
31
+ return processConfig.replicas > 1 ? `${processConfig.id}#${index}` : processConfig.id
32
+ }
33
+
25
34
  export default class ReleaseGroup extends EventEmitter {
26
35
  /**
27
36
  * @param {object} args - Options.
@@ -44,6 +53,7 @@ export default class ReleaseGroup extends EventEmitter {
44
53
  this.connectionCount = 0
45
54
  this.connections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
46
55
  this.processes = /** @type {Map<string, ManagedProcess>} */ (new Map())
56
+ this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
47
57
  this.ports = /** @type {Record<string, number>} */ ({})
48
58
  this.servicePorts = servicePorts
49
59
  this.portsAllocated = false
@@ -60,9 +70,14 @@ export default class ReleaseGroup extends EventEmitter {
60
70
  await this.allocatePorts()
61
71
 
62
72
  for (const processConfig of this.releaseProcessStartOrder()) {
63
- const processInstance = this.buildProcess(processConfig)
64
- this.processes.set(processConfig.id, processInstance)
65
- await processInstance.start()
73
+ for (let index = 0; index < processConfig.replicas; index += 1) {
74
+ const instanceId = replicaInstanceId(processConfig, index)
75
+ const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId})
76
+
77
+ this.processes.set(instanceId, processInstance)
78
+ if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instanceId)
79
+ await processInstance.start("deploy")
80
+ }
66
81
 
67
82
  if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
68
83
  await waitForHealth({
@@ -80,6 +95,33 @@ export default class ReleaseGroup extends EventEmitter {
80
95
  }
81
96
  }
82
97
 
98
+ /**
99
+ * @param {string} id - Process id.
100
+ * @returns {ManagedProcess | undefined} This release's managed process with the given id, if present.
101
+ */
102
+ getProcess(id) {
103
+ return this.processes.get(id)
104
+ }
105
+
106
+ /**
107
+ * Returns the running instances of a process config — one for a single process, or every
108
+ * replica (`id#0`, `id#1`, …) for a replicated one.
109
+ * @param {string} configId - Base process id from the config.
110
+ * @returns {{id: string, process: ManagedProcess}[]} Matching instances, ordered by instance id.
111
+ */
112
+ getProcesses(configId) {
113
+ /** @type {{id: string, process: ManagedProcess}[]} */
114
+ const instances = []
115
+
116
+ for (const [instanceId, processInstance] of this.processes) {
117
+ if (instanceId === configId || instanceId.startsWith(`${configId}#`)) {
118
+ instances.push({id: instanceId, process: processInstance})
119
+ }
120
+ }
121
+
122
+ return instances
123
+ }
124
+
83
125
  /**
84
126
  * Logs process diagnostics before failed startup cleanup stops and removes the release processes.
85
127
  * @param {Error | string} error - Startup failure.
@@ -156,10 +198,13 @@ export default class ReleaseGroup extends EventEmitter {
156
198
  * @returns {ManagedProcess} Managed process.
157
199
  */
158
200
  buildProcess(processConfig, options = {}) {
159
- const context = this.contextForProcess(processConfig)
201
+ const index = options.index ?? 0
202
+ const count = options.count ?? 1
203
+ const instanceId = options.instanceId ?? processConfig.id
204
+ const context = this.contextForProcess(processConfig, {count, index})
160
205
  const renderedEnv = /** @type {Record<string, string>} */ (renderObject(processConfig.env, context))
161
206
  const processEnv = {
162
- ...this.baseEnvironment(processConfig),
207
+ ...this.baseEnvironment(processConfig, {count, index}),
163
208
  ...renderedEnv
164
209
  }
165
210
 
@@ -167,26 +212,33 @@ export default class ReleaseGroup extends EventEmitter {
167
212
  command: renderTemplate(processConfig.command, context),
168
213
  cwd: processConfig.cwd ? renderTemplate(processConfig.cwd, context) : this.releasePath,
169
214
  env: processEnv,
170
- id: processConfig.id,
171
- logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
215
+ id: instanceId,
216
+ lifecycle: processConfig.lifecycle,
217
+ logger: (message, data = {}) => this.logger(message, {processId: instanceId, releaseId: this.releaseId, ...data}),
218
+ memory: processConfig.memory,
172
219
  outputLines: processConfig.outputLines,
220
+ restart: processConfig.restart,
173
221
  restartDelayMs: processConfig.restartDelayMs,
174
222
  shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
223
+ stopSignal: processConfig.stopSignal,
175
224
  stopTimeoutMs: processConfig.gracefulStopMs
176
225
  })
177
226
  }
178
227
 
179
228
  /**
180
229
  * @param {import("./config.js").ProcessConfig} processConfig - Process config.
230
+ * @param {{count: number, index: number}} replica - Replica index and total count.
181
231
  * @returns {Record<string, string>} Base environment.
182
232
  */
183
- baseEnvironment(processConfig) {
233
+ baseEnvironment(processConfig, replica = {count: 1, index: 0}) {
184
234
  /** @type {Record<string, string>} */
185
235
  const env = {
186
236
  ROLLBRIDGE_APPLICATION: this.config.application,
187
237
  ROLLBRIDGE_PROCESS_ID: processConfig.id,
188
238
  ROLLBRIDGE_RELEASE_ID: this.releaseId,
189
239
  ROLLBRIDGE_RELEASE_PATH: this.releasePath,
240
+ ROLLBRIDGE_REPLICA_COUNT: String(replica.count),
241
+ ROLLBRIDGE_REPLICA_INDEX: String(replica.index),
190
242
  ROLLBRIDGE_REVISION: this.revision
191
243
  }
192
244
 
@@ -203,24 +255,21 @@ export default class ReleaseGroup extends EventEmitter {
203
255
 
204
256
  /**
205
257
  * @param {import("./config.js").ProcessConfig} processConfig - Process config.
258
+ * @param {{count: number, index: number}} replica - Replica index and total count.
206
259
  * @returns {Record<string, JsonValue>} Template context.
207
260
  */
208
- contextForProcess(processConfig) {
209
- return {
261
+ contextForProcess(processConfig, replica = {count: 1, index: 0}) {
262
+ return processTemplateContext({
210
263
  application: this.config.application,
211
- env: {...process.env},
212
- port: this.ports[processConfig.id],
213
264
  ports: this.ports,
214
265
  processId: processConfig.id,
215
- proxy: {
216
- host: this.config.proxy.host,
217
- port: this.config.proxy.port,
218
- upstreamHost: this.config.proxy.upstreamHost
219
- },
266
+ proxy: this.config.proxy,
220
267
  releaseId: this.releaseId,
221
268
  releasePath: this.releasePath,
269
+ replicaCount: replica.count,
270
+ replicaIndex: replica.index,
222
271
  revision: this.revision
223
- }
272
+ })
224
273
  }
225
274
 
226
275
  /**
@@ -277,6 +326,13 @@ export default class ReleaseGroup extends EventEmitter {
277
326
  this.state = "draining"
278
327
  this.drainStartedAt = new Date().toISOString()
279
328
 
329
+ // Stop nonBlockingDrain processes (e.g. job workers) immediately and in the background, so
330
+ // their lifecycle drain runs as soon as the release is retired — in parallel with the
331
+ // connection drain, not held until after it. The rest stop once connections have closed.
332
+ const entries = [...this.processes.entries()]
333
+ const nonBlockingStops = entries.filter(([id]) => this.nonBlockingDrainIds.has(id)).map(([, processInstance]) => processInstance.stop())
334
+ const connectionDependent = entries.filter(([id]) => !this.nonBlockingDrainIds.has(id)).map(([, processInstance]) => processInstance)
335
+
280
336
  if (this.connectionCount > 0) {
281
337
  await new Promise((resolve) => {
282
338
  const timer = setTimeout(resolve, timeoutMs)
@@ -287,7 +343,10 @@ export default class ReleaseGroup extends EventEmitter {
287
343
  })
288
344
  }
289
345
 
290
- await this.stop()
346
+ await Promise.allSettled(connectionDependent.map((processInstance) => processInstance.stop()))
347
+ await Promise.allSettled(nonBlockingStops)
348
+ this.state = "stopped"
349
+ this.stoppedAt = new Date().toISOString()
291
350
  }
292
351
 
293
352
  /** @returns {Promise<void>} Stops all release-owned processes. */
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs/promises"
4
+
5
+ /**
6
+ * @typedef {import("./json.js").JsonValue} JsonValue
7
+ */
8
+
9
+ let tempCounter = 0
10
+
11
+ /**
12
+ * Atomically writes a daemon state snapshot to a file (write to a unique temp file, then
13
+ * rename), so a reader never sees a partially written file and concurrent writes don't race
14
+ * a shared temp path.
15
+ * @param {string} path - State file path.
16
+ * @param {JsonValue} state - State snapshot to persist.
17
+ * @returns {Promise<void>} Resolves once written.
18
+ */
19
+ export async function writeState(path, state) {
20
+ tempCounter += 1
21
+
22
+ const tempPath = `${path}.${process.pid}.${tempCounter}.tmp`
23
+
24
+ await fs.writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`)
25
+ await fs.rename(tempPath, path)
26
+ }
27
+
28
+ /**
29
+ * Reads a previously persisted state snapshot.
30
+ * @param {string} path - State file path.
31
+ * @returns {Promise<JsonValue | undefined>} The parsed snapshot, or undefined when missing or unparseable.
32
+ */
33
+ export async function readState(path) {
34
+ let contents
35
+
36
+ try {
37
+ contents = await fs.readFile(path, "utf8")
38
+ } catch {
39
+ return undefined
40
+ }
41
+
42
+ try {
43
+ return JSON.parse(contents)
44
+ } catch {
45
+ return undefined
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Removes a state file, ignoring a missing file.
51
+ * @param {string} path - State file path.
52
+ * @returns {Promise<void>} Resolves once removed.
53
+ */
54
+ export async function clearState(path) {
55
+ await fs.rm(path, {force: true})
56
+ }
57
+
58
+ /**
59
+ * @param {number} pid - Process id to probe.
60
+ * @returns {boolean} True when a process with this pid exists (alive).
61
+ */
62
+ export function isProcessAlive(pid) {
63
+ try {
64
+ process.kill(pid, 0)
65
+
66
+ return true
67
+ } catch (error) {
68
+ // EPERM means the process exists but is owned by another user — still alive.
69
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM")
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Finds managed processes recorded in a persisted snapshot whose pids are still alive — the
75
+ * orphans left by a daemon that did not shut down cleanly. Advisory (a recycled pid can be a
76
+ * false positive); returns an empty list for a missing or unexpectedly shaped snapshot.
77
+ * @param {JsonValue | undefined} state - A persisted state snapshot (from {@link readState}).
78
+ * @param {(pid: number) => boolean} [alive] - Process-liveness probe (defaults to {@link isProcessAlive}).
79
+ * @returns {{id: string, pid: number, releaseId: string | null}[]} Live persisted processes.
80
+ */
81
+ export function liveProcesses(state, alive = isProcessAlive) {
82
+ if (!state) return []
83
+
84
+ const live = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
85
+
86
+ try {
87
+ const snapshot = /** @type {import("./daemon.js").DaemonStatus} */ (state)
88
+
89
+ for (const release of snapshot.releases) {
90
+ for (const process of release.processes) {
91
+ if (typeof process.pid === "number" && alive(process.pid)) live.push({id: process.id, pid: process.pid, releaseId: release.releaseId})
92
+ }
93
+ }
94
+
95
+ for (const {id, process} of [...snapshot.services, ...snapshot.singletons]) {
96
+ if (typeof process.pid === "number" && alive(process.pid)) live.push({id, pid: process.pid, releaseId: null})
97
+ }
98
+ } catch {
99
+ return []
100
+ }
101
+
102
+ return live
103
+ }
@@ -0,0 +1,71 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs"
4
+
5
+ /**
6
+ * Resolves a control-socket owner (a numeric uid, a numeric string, or a user name)
7
+ * to a numeric uid. Names are looked up in `/etc/passwd`.
8
+ * @param {number | string} owner - User id or name.
9
+ * @returns {number} The numeric uid.
10
+ */
11
+ export function resolveUserId(owner) {
12
+ return resolvePrincipalId(owner, "/etc/passwd", "user")
13
+ }
14
+
15
+ /**
16
+ * Resolves a control-socket group (a numeric gid, a numeric string, or a group name)
17
+ * to a numeric gid. Names are looked up in `/etc/group`.
18
+ * @param {number | string} group - Group id or name.
19
+ * @returns {number} The numeric gid.
20
+ */
21
+ export function resolveGroupId(group) {
22
+ return resolvePrincipalId(group, "/etc/group", "group")
23
+ }
24
+
25
+ /**
26
+ * @param {number | string} value - Numeric id, numeric string, or name.
27
+ * @param {string} file - System database file mapping names to ids.
28
+ * @param {"user" | "group"} kind - Principal kind, for error messages.
29
+ * @returns {number} The numeric id.
30
+ */
31
+ function resolvePrincipalId(value, file, kind) {
32
+ if (typeof value === "number") return value
33
+ if (/^\d+$/.test(value)) return Number(value)
34
+
35
+ const id = lookupByName(value, file)
36
+
37
+ if (id === undefined) {
38
+ throw new Error(`Unknown ${kind} "${value}". Use a numeric id, or ensure the ${kind} exists in ${file} (name resolution covers local ${kind}s only).`)
39
+ }
40
+
41
+ return id
42
+ }
43
+
44
+ /**
45
+ * @param {string} name - Principal name.
46
+ * @param {string} file - `/etc/passwd` or `/etc/group`.
47
+ * @returns {number | undefined} The numeric id, or undefined when the name is not found.
48
+ */
49
+ function lookupByName(name, file) {
50
+ let contents
51
+
52
+ try {
53
+ contents = fs.readFileSync(file, "utf8")
54
+ } catch {
55
+ return undefined
56
+ }
57
+
58
+ for (const line of contents.split("\n")) {
59
+ if (!line || line.startsWith("#")) continue
60
+
61
+ const fields = line.split(":")
62
+
63
+ if (fields[0] === name) {
64
+ const id = Number(fields[2])
65
+
66
+ if (Number.isInteger(id)) return id
67
+ }
68
+ }
69
+
70
+ return undefined
71
+ }
package/src/template.js CHANGED
@@ -44,6 +44,38 @@ export function renderTemplate(value, context) {
44
44
  })
45
45
  }
46
46
 
47
+ /**
48
+ * Builds the template context for rendering one process's command, cwd, and env. It is the
49
+ * single source of truth for the render context so callers stay in sync — the daemon uses it at
50
+ * deploy time, and `doctor` uses it (with representative ports) to pre-flight a release.
51
+ * @param {object} args - Context inputs.
52
+ * @param {string} args.application - Application name.
53
+ * @param {Record<string, number>} args.ports - Allocated (or representative) ports by process id.
54
+ * @param {string} args.processId - The process this context renders.
55
+ * @param {{host: string, port: number, upstreamHost: string}} args.proxy - Proxy address.
56
+ * @param {string} args.releaseId - Release id.
57
+ * @param {string} args.releasePath - Release directory.
58
+ * @param {number} args.replicaCount - Total replicas configured for this process.
59
+ * @param {number} args.replicaIndex - This replica's index.
60
+ * @param {string} args.revision - Release revision.
61
+ * @returns {TemplateContext} The render context.
62
+ */
63
+ export function processTemplateContext({application, ports, processId, proxy, releaseId, releasePath, replicaCount, replicaIndex, revision}) {
64
+ return {
65
+ application,
66
+ env: {...process.env},
67
+ port: ports[processId],
68
+ ports,
69
+ processId,
70
+ proxy: {host: proxy.host, port: proxy.port, upstreamHost: proxy.upstreamHost},
71
+ releaseId,
72
+ releasePath,
73
+ replicaCount,
74
+ replicaIndex,
75
+ revision
76
+ }
77
+ }
78
+
47
79
  /**
48
80
  * Renders all string values in a plain JSON-like object.
49
81
  * @param {JsonValue} value - Value to render.
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import test from "node:test"
5
+ import {runCli} from "../src/cli.js"
6
+
7
+ /**
8
+ * Runs the CLI while capturing stdout, stderr, and the resulting exit code.
9
+ * @param {string[]} argv - Process argv.
10
+ * @returns {Promise<{code: number | string | undefined, errorOutput: string, output: string}>} Captured output and exit code.
11
+ */
12
+ async function capture(argv) {
13
+ const originalLog = console.log
14
+ const originalError = console.error
15
+ const originalExitCode = process.exitCode
16
+ /** @type {string[]} */
17
+ const out = []
18
+ /** @type {string[]} */
19
+ const err = []
20
+
21
+ console.log = (/** @type {string[]} */ ...args) => { out.push(args.map((arg) => String(arg)).join(" ")) }
22
+ console.error = (/** @type {string[]} */ ...args) => { err.push(args.map((arg) => String(arg)).join(" ")) }
23
+ process.exitCode = 0
24
+
25
+ try {
26
+ await runCli(argv)
27
+ } finally {
28
+ console.log = originalLog
29
+ console.error = originalError
30
+ }
31
+
32
+ const code = process.exitCode
33
+
34
+ process.exitCode = originalExitCode
35
+
36
+ return {code, errorOutput: err.join("\n"), output: out.join("\n")}
37
+ }
38
+
39
+ test("completion bash prints a sourceable script with commands and option flags", async () => {
40
+ const {code, output} = await capture(["node", "rollbridge", "completion", "bash"])
41
+
42
+ assert.notEqual(code, 1)
43
+ assert.match(output, /complete -F _rollbridge rollbridge/)
44
+ assert.match(output, /compgen -W "daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events recover completion"/)
45
+ // A command's own options are completed after the command.
46
+ assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
47
+ assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
48
+ })
49
+
50
+ test("completion zsh prints a #compdef script with per-command options", async () => {
51
+ const {output} = await capture(["node", "rollbridge", "completion", "zsh"])
52
+
53
+ assert.match(output, /^#compdef rollbridge/)
54
+ assert.match(output, /compdef _rollbridge rollbridge/)
55
+ assert.match(output, /commands=\(daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events recover completion\)/)
56
+ assert.match(output, /events\) compadd -- [^\n]*--limit/)
57
+ })
58
+
59
+ test("completion rejects an unsupported shell with a non-zero exit code", async () => {
60
+ const {code, errorOutput} = await capture(["node", "rollbridge", "completion", "fish"])
61
+
62
+ assert.equal(code, 1)
63
+ assert.match(errorOutput, /Unsupported shell "fish"\. Supported shells: bash, zsh\./)
64
+ })