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.
- package/README.md +137 -4
- package/TODO.md +47 -45
- package/docs/cli.md +169 -6
- package/docs/config.md +160 -3
- package/docs/logging.md +77 -0
- package/docs/nginx.md +104 -0
- package/docs/releasing.md +53 -0
- package/docs/tensorbuzz-runbook.md +129 -0
- package/docs/velocious.md +238 -0
- package/docs/workers.md +115 -0
- package/package.json +3 -2
- package/src/cli.js +317 -1
- package/src/config.js +240 -6
- package/src/daemon.js +284 -4
- package/src/doctor.js +177 -0
- package/src/event-log.js +47 -0
- package/src/managed-process.js +287 -22
- package/src/process-memory.js +110 -0
- package/src/recover.js +134 -0
- package/src/release-group.js +80 -21
- package/src/state-store.js +103 -0
- package/src/system-ids.js +71 -0
- package/src/template.js +32 -0
- package/test/completion.test.js +64 -0
- package/test/config-validation.test.js +267 -0
- package/test/doctor.test.js +205 -3
- package/test/event-log.test.js +46 -0
- package/test/fixtures/memory-hog.js +19 -0
- package/test/managed-process.test.js +376 -0
- package/test/process-memory.test.js +40 -0
- package/test/recover.test.js +162 -0
- package/test/release-group.test.js +22 -0
- package/test/rollbridge.test.js +716 -6
- package/test/state-store.test.js +69 -0
- package/test/system-ids.test.js +24 -0
- package/scripts/release-patch.js +0 -83
package/src/release-group.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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:
|
|
171
|
-
|
|
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
|
|
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
|
+
})
|