rollbridge 0.1.5 → 0.1.7
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 +125 -4
- package/TODO.md +45 -43
- package/docs/cli.md +166 -6
- package/docs/config.md +172 -2
- package/docs/logging.md +77 -0
- package/docs/releasing.md +53 -0
- package/docs/tensorbuzz-runbook.md +129 -0
- package/docs/velocious.md +49 -11
- package/docs/workers.md +115 -0
- package/package.json +1 -1
- package/src/cli.js +327 -1
- package/src/config.js +268 -6
- package/src/daemon.js +216 -13
- package/src/doctor.js +177 -0
- package/src/event-log.js +47 -0
- package/src/managed-process.js +225 -16
- package/src/predeploy-cleanup.js +340 -0
- package/src/process-memory.js +110 -0
- package/src/recover.js +134 -0
- package/src/release-group.js +71 -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 +268 -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 +290 -0
- package/test/predeploy-cleanup.test.js +131 -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 +523 -6
- package/test/state-store.test.js +69 -0
- package/test/system-ids.test.js +24 -0
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({
|
|
@@ -88,6 +103,25 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
88
103
|
return this.processes.get(id)
|
|
89
104
|
}
|
|
90
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
|
+
|
|
91
125
|
/**
|
|
92
126
|
* Logs process diagnostics before failed startup cleanup stops and removes the release processes.
|
|
93
127
|
* @param {Error | string} error - Startup failure.
|
|
@@ -164,10 +198,13 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
164
198
|
* @returns {ManagedProcess} Managed process.
|
|
165
199
|
*/
|
|
166
200
|
buildProcess(processConfig, options = {}) {
|
|
167
|
-
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})
|
|
168
205
|
const renderedEnv = /** @type {Record<string, string>} */ (renderObject(processConfig.env, context))
|
|
169
206
|
const processEnv = {
|
|
170
|
-
...this.baseEnvironment(processConfig),
|
|
207
|
+
...this.baseEnvironment(processConfig, {count, index}),
|
|
171
208
|
...renderedEnv
|
|
172
209
|
}
|
|
173
210
|
|
|
@@ -175,27 +212,33 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
175
212
|
command: renderTemplate(processConfig.command, context),
|
|
176
213
|
cwd: processConfig.cwd ? renderTemplate(processConfig.cwd, context) : this.releasePath,
|
|
177
214
|
env: processEnv,
|
|
178
|
-
id:
|
|
179
|
-
|
|
215
|
+
id: instanceId,
|
|
216
|
+
lifecycle: processConfig.lifecycle,
|
|
217
|
+
logger: (message, data = {}) => this.logger(message, {processId: instanceId, releaseId: this.releaseId, ...data}),
|
|
218
|
+
memory: processConfig.memory,
|
|
180
219
|
outputLines: processConfig.outputLines,
|
|
181
220
|
restart: processConfig.restart,
|
|
182
221
|
restartDelayMs: processConfig.restartDelayMs,
|
|
183
222
|
shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
|
|
223
|
+
stopSignal: processConfig.stopSignal,
|
|
184
224
|
stopTimeoutMs: processConfig.gracefulStopMs
|
|
185
225
|
})
|
|
186
226
|
}
|
|
187
227
|
|
|
188
228
|
/**
|
|
189
229
|
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
230
|
+
* @param {{count: number, index: number}} replica - Replica index and total count.
|
|
190
231
|
* @returns {Record<string, string>} Base environment.
|
|
191
232
|
*/
|
|
192
|
-
baseEnvironment(processConfig) {
|
|
233
|
+
baseEnvironment(processConfig, replica = {count: 1, index: 0}) {
|
|
193
234
|
/** @type {Record<string, string>} */
|
|
194
235
|
const env = {
|
|
195
236
|
ROLLBRIDGE_APPLICATION: this.config.application,
|
|
196
237
|
ROLLBRIDGE_PROCESS_ID: processConfig.id,
|
|
197
238
|
ROLLBRIDGE_RELEASE_ID: this.releaseId,
|
|
198
239
|
ROLLBRIDGE_RELEASE_PATH: this.releasePath,
|
|
240
|
+
ROLLBRIDGE_REPLICA_COUNT: String(replica.count),
|
|
241
|
+
ROLLBRIDGE_REPLICA_INDEX: String(replica.index),
|
|
199
242
|
ROLLBRIDGE_REVISION: this.revision
|
|
200
243
|
}
|
|
201
244
|
|
|
@@ -212,24 +255,21 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
212
255
|
|
|
213
256
|
/**
|
|
214
257
|
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
258
|
+
* @param {{count: number, index: number}} replica - Replica index and total count.
|
|
215
259
|
* @returns {Record<string, JsonValue>} Template context.
|
|
216
260
|
*/
|
|
217
|
-
contextForProcess(processConfig) {
|
|
218
|
-
return {
|
|
261
|
+
contextForProcess(processConfig, replica = {count: 1, index: 0}) {
|
|
262
|
+
return processTemplateContext({
|
|
219
263
|
application: this.config.application,
|
|
220
|
-
env: {...process.env},
|
|
221
|
-
port: this.ports[processConfig.id],
|
|
222
264
|
ports: this.ports,
|
|
223
265
|
processId: processConfig.id,
|
|
224
|
-
proxy:
|
|
225
|
-
host: this.config.proxy.host,
|
|
226
|
-
port: this.config.proxy.port,
|
|
227
|
-
upstreamHost: this.config.proxy.upstreamHost
|
|
228
|
-
},
|
|
266
|
+
proxy: this.config.proxy,
|
|
229
267
|
releaseId: this.releaseId,
|
|
230
268
|
releasePath: this.releasePath,
|
|
269
|
+
replicaCount: replica.count,
|
|
270
|
+
replicaIndex: replica.index,
|
|
231
271
|
revision: this.revision
|
|
232
|
-
}
|
|
272
|
+
})
|
|
233
273
|
}
|
|
234
274
|
|
|
235
275
|
/**
|
|
@@ -286,6 +326,13 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
286
326
|
this.state = "draining"
|
|
287
327
|
this.drainStartedAt = new Date().toISOString()
|
|
288
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
|
+
|
|
289
336
|
if (this.connectionCount > 0) {
|
|
290
337
|
await new Promise((resolve) => {
|
|
291
338
|
const timer = setTimeout(resolve, timeoutMs)
|
|
@@ -296,7 +343,10 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
296
343
|
})
|
|
297
344
|
}
|
|
298
345
|
|
|
299
|
-
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()
|
|
300
350
|
}
|
|
301
351
|
|
|
302
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 predeploy-cleanup 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 predeploy-cleanup 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
|
+
})
|