@akash-chowdhury-24/deployhub 2.0.22 → 2.0.26
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/package.json +1 -1
- package/src/deployment/providers/ssh.js +117 -21
package/package.json
CHANGED
|
@@ -55,6 +55,14 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
55
55
|
|
|
56
56
|
const log = createLogger('ssh');
|
|
57
57
|
|
|
58
|
+
// Defense-in-depth: never let a stuck SSH channel hang CI indefinitely.
|
|
59
|
+
// Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms). Backend start/stop uses a shorter bound.
|
|
60
|
+
const defaultExecTimeoutMs = Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) || 120_000;
|
|
61
|
+
const startStopTimeoutMs = Math.min(
|
|
62
|
+
defaultExecTimeoutMs,
|
|
63
|
+
Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS) || 60_000
|
|
64
|
+
);
|
|
65
|
+
|
|
58
66
|
async function connect() {
|
|
59
67
|
if (!host || !user) {
|
|
60
68
|
throw new Error(
|
|
@@ -95,10 +103,33 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
95
103
|
/**
|
|
96
104
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
97
105
|
* @param {string} command
|
|
106
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
98
107
|
*/
|
|
99
|
-
async function exec(ssh, command) {
|
|
108
|
+
async function exec(ssh, command, opts = {}) {
|
|
109
|
+
const timeoutMs = opts.timeoutMs ?? defaultExecTimeoutMs;
|
|
100
110
|
log.info(`$ ${command}`);
|
|
101
|
-
|
|
111
|
+
|
|
112
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
113
|
+
let timer;
|
|
114
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
115
|
+
timer = setTimeout(() => {
|
|
116
|
+
reject(
|
|
117
|
+
new Error(
|
|
118
|
+
`SSH command timed out after ${timeoutMs}ms on ${user}@${host}. ` +
|
|
119
|
+
`The remote command may still be running — check the server. ` +
|
|
120
|
+
`Command: ${command.length > 240 ? `${command.slice(0, 240)}…` : command}`
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let result;
|
|
127
|
+
try {
|
|
128
|
+
result = await Promise.race([ssh.execCommand(command), timeoutPromise]);
|
|
129
|
+
} finally {
|
|
130
|
+
if (timer) clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
|
|
102
133
|
if (result.code !== 0 && result.code !== null) {
|
|
103
134
|
const message = formatRemoteCommandFailure(
|
|
104
135
|
command,
|
|
@@ -112,6 +143,47 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
112
143
|
return result;
|
|
113
144
|
}
|
|
114
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Exact marker match in a null-delimited /proc file (cmdline or environ).
|
|
148
|
+
* Uses grep -xF so DEPLOYHUB_APP=myapi does not match DEPLOYHUB_APP=myapi-staging.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} procFileExpr — e.g. `/proc/$pid/environ` or `$proc/cmdline`
|
|
151
|
+
* @param {string} marker — already shell-quoted
|
|
152
|
+
*/
|
|
153
|
+
function procHasExactMarker(procFileExpr, marker) {
|
|
154
|
+
return (
|
|
155
|
+
`tr '\\0' '\\n' < ${procFileExpr} 2>/dev/null | grep -qxF ${marker}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Kill every process whose environ or cmdline contains our exact env/JVM marker.
|
|
161
|
+
* Replaces `pkill -f DEPLOYHUB_APP=…` which only searches cmdline — and
|
|
162
|
+
* `VAR=value nohup cmd` puts the marker in environ only, so orphans from
|
|
163
|
+
* interrupted deploys (no pidfile) were never found.
|
|
164
|
+
*
|
|
165
|
+
* Safe against PID reuse: an unrelated process will not carry our marker.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} markerEnvQ — shell-quoted `DEPLOYHUB_APP=…`
|
|
168
|
+
* @param {string} markerJvmQ — shell-quoted `deployhub.app=…`
|
|
169
|
+
* @param {string} markerJvmFlagQ — shell-quoted `-Ddeployhub.app=…`
|
|
170
|
+
*/
|
|
171
|
+
function killByExactMarkersCmd(markerEnvQ, markerJvmQ, markerJvmFlagQ) {
|
|
172
|
+
return (
|
|
173
|
+
`for proc in /proc/[0-9]*; do ` +
|
|
174
|
+
`pid="\${proc##*/}"; ` +
|
|
175
|
+
`matched=0; ` +
|
|
176
|
+
`if [ -r "$proc/environ" ] && ${procHasExactMarker('"$proc/environ"', markerEnvQ)}; then matched=1; fi; ` +
|
|
177
|
+
`if [ "$matched" -eq 0 ] && [ -r "$proc/cmdline" ]; then ` +
|
|
178
|
+
`if ${procHasExactMarker('"$proc/cmdline"', markerEnvQ)} ` +
|
|
179
|
+
`|| ${procHasExactMarker('"$proc/cmdline"', markerJvmFlagQ)} ` +
|
|
180
|
+
`|| ${procHasExactMarker('"$proc/cmdline"', markerJvmQ)}; then matched=1; fi; ` +
|
|
181
|
+
`fi; ` +
|
|
182
|
+
`if [ "$matched" -eq 1 ]; then kill "$pid" 2>/dev/null || true; fi; ` +
|
|
183
|
+
`done`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
/**
|
|
116
188
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
117
189
|
*/
|
|
@@ -141,7 +213,9 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
141
213
|
* PID-file kill is gated: we only signal a PID if /proc shows our
|
|
142
214
|
* DEPLOYHUB_APP / deployhub.app marker in cmdline or environ. A stale PID
|
|
143
215
|
* reused by an unrelated process is left alone (file still removed).
|
|
144
|
-
*
|
|
216
|
+
*
|
|
217
|
+
* Fallback scans /proc/[pid]/environ (and cmdline) for the exact marker -
|
|
218
|
+
* `pkill -f` cannot see env-only markers from `VAR=value cmd` starts.
|
|
145
219
|
*
|
|
146
220
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
147
221
|
* @param {string} targetPath
|
|
@@ -150,26 +224,35 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
150
224
|
const pidFile = `${targetPath}/.deployhub.pid`;
|
|
151
225
|
const markerEnv = `DEPLOYHUB_APP=${appName}`;
|
|
152
226
|
const markerJvm = `deployhub.app=${appName}`;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
227
|
+
const markerEnvQ = sh(markerEnv);
|
|
228
|
+
const markerJvmQ = sh(markerJvm);
|
|
229
|
+
const markerJvmFlagQ = sh(`-D${markerJvm}`);
|
|
230
|
+
|
|
231
|
+
// Verify-then-kill: only signal a PID if /proc shows our exact marker.
|
|
232
|
+
// Exact (-xF) match so DEPLOYHUB_APP=myapi does not hit myapi-staging.
|
|
156
233
|
await exec(
|
|
157
234
|
ssh,
|
|
158
235
|
`if [ -f ${sh(pidFile)} ]; then ` +
|
|
159
236
|
`pid="$(cat ${sh(pidFile)} 2>/dev/null | tr -cd '0-9')"; ` +
|
|
160
|
-
`if [ -n "$pid" ] && [ -
|
|
161
|
-
`
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
`
|
|
237
|
+
`if [ -n "$pid" ] && [ -d "/proc/$pid" ]; then ` +
|
|
238
|
+
`matched=0; ` +
|
|
239
|
+
`if [ -r "/proc/$pid/environ" ] && ${procHasExactMarker(`"/proc/$pid/environ"`, markerEnvQ)}; then matched=1; fi; ` +
|
|
240
|
+
`if [ "$matched" -eq 0 ] && [ -r "/proc/$pid/cmdline" ]; then ` +
|
|
241
|
+
`if ${procHasExactMarker(`"/proc/$pid/cmdline"`, markerEnvQ)} ` +
|
|
242
|
+
`|| ${procHasExactMarker(`"/proc/$pid/cmdline"`, markerJvmFlagQ)} ` +
|
|
243
|
+
`|| ${procHasExactMarker(`"/proc/$pid/cmdline"`, markerJvmQ)}; then matched=1; fi; ` +
|
|
165
244
|
`fi; ` +
|
|
245
|
+
`if [ "$matched" -eq 1 ]; then kill "$pid" 2>/dev/null || true; fi; ` +
|
|
166
246
|
`fi; ` +
|
|
167
247
|
`rm -f ${sh(pidFile)}; ` +
|
|
168
|
-
`fi
|
|
248
|
+
`fi`,
|
|
249
|
+
{ timeoutMs: startStopTimeoutMs }
|
|
169
250
|
);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await exec(ssh,
|
|
251
|
+
|
|
252
|
+
// Orphan fallback: no/stale pidfile — find by exact marker in environ or cmdline.
|
|
253
|
+
await exec(ssh, killByExactMarkersCmd(markerEnvQ, markerJvmQ, markerJvmFlagQ), {
|
|
254
|
+
timeoutMs: startStopTimeoutMs,
|
|
255
|
+
});
|
|
173
256
|
}
|
|
174
257
|
|
|
175
258
|
/**
|
|
@@ -194,7 +277,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
194
277
|
`fi`;
|
|
195
278
|
|
|
196
279
|
try {
|
|
197
|
-
await exec(ssh, verifyCmd);
|
|
280
|
+
await exec(ssh, verifyCmd, { timeoutMs: startStopTimeoutMs });
|
|
198
281
|
} catch (err) {
|
|
199
282
|
const detail = err instanceof Error ? err.message : String(err);
|
|
200
283
|
throw new Error(
|
|
@@ -206,8 +289,16 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
206
289
|
|
|
207
290
|
/**
|
|
208
291
|
* Start a nohup process with DEPLOYHUB_APP marker and write PID file.
|
|
209
|
-
*
|
|
210
|
-
*
|
|
292
|
+
* Marker is set in environ AND embedded as argv0 via `bash exec -a` so it
|
|
293
|
+
* appears in /proc/cmdline (cmdline scans can see it). Plain
|
|
294
|
+
* `VAR=value cmd` alone only puts the marker in environ.
|
|
295
|
+
*
|
|
296
|
+
* Critical shell-precedence note: `cd dir && nohup cmd & echo $!` is parsed
|
|
297
|
+
* as `(cd dir && nohup cmd) & echo $!`. Backgrounding that AND-list leaves
|
|
298
|
+
* the SSH session's bash waiting on the still-running app, so node-ssh never
|
|
299
|
+
* sees channel completion (false deploy failure) even though the process
|
|
300
|
+
* started. Brace-group so only nohup is backgrounded:
|
|
301
|
+
* `cd dir && { nohup cmd & echo $!; }`
|
|
211
302
|
*
|
|
212
303
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
213
304
|
* @param {string} targetPath
|
|
@@ -216,9 +307,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
216
307
|
async function startScopedNohup(ssh, targetPath, command) {
|
|
217
308
|
const pidFile = `${targetPath}/.deployhub.pid`;
|
|
218
309
|
const dir = sh(targetPath);
|
|
310
|
+
const markerArg = sh(`DEPLOYHUB_APP=${appName}`);
|
|
311
|
+
// stdin from /dev/null + redirects: avoid SSH waiting on leftover FDs.
|
|
312
|
+
// bash exec -a puts DEPLOYHUB_APP=… in argv0 of the real process after exec.
|
|
219
313
|
await exec(
|
|
220
314
|
ssh,
|
|
221
|
-
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} nohup ${command} > app.log 2>&1 & echo $! > ${sh(pidFile)}
|
|
315
|
+
`cd ${dir} && { DEPLOYHUB_APP=${sh(appName)} nohup bash -c 'exec -a "$0" "$@"' ${markerArg} ${command} > app.log 2>&1 </dev/null & echo $! > ${sh(pidFile)}; }`,
|
|
316
|
+
{ timeoutMs: startStopTimeoutMs }
|
|
222
317
|
);
|
|
223
318
|
await assertPidAliveAfterStart(ssh, targetPath);
|
|
224
319
|
}
|
|
@@ -283,7 +378,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
283
378
|
ssh,
|
|
284
379
|
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} ` +
|
|
285
380
|
`--name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} ` +
|
|
286
|
-
`--pid ${sh(pidFile)} --error-logfile ${sh(logFile)} --capture-output --daemon
|
|
381
|
+
`--pid ${sh(pidFile)} --error-logfile ${sh(logFile)} --capture-output --daemon`,
|
|
382
|
+
{ timeoutMs: startStopTimeoutMs }
|
|
287
383
|
);
|
|
288
384
|
await assertPidAliveAfterStart(ssh, targetPath, logFile);
|
|
289
385
|
}
|
|
@@ -306,7 +402,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
306
402
|
if (framework === 'spring' || framework === 'java') {
|
|
307
403
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
308
404
|
// -Ddeployhub.app= embeds the env-scoped identity in the JVM command line
|
|
309
|
-
// so
|
|
405
|
+
// so cmdline marker scans and the PID file both target only this env.
|
|
310
406
|
await startScopedNohup(
|
|
311
407
|
ssh,
|
|
312
408
|
targetPath,
|