@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.22",
3
+ "version": "2.0.26",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -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
- const result = await ssh.execCommand(command);
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
- * Marker-based pkill remains the primary stop for live processes.
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
- // Verify-then-kill: only signal a PID if /proc shows our marker in cmdline
154
- // or environ. A stale PID reused by an unrelated process is left alone
155
- // (the pidfile is still removed so the next start writes a fresh one).
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" ] && [ -r "/proc/$pid/cmdline" ]; then ` +
161
- `if tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -F ${sh(markerEnv)} ` +
162
- `|| tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -F ${sh(markerJvm)} ` +
163
- `|| { [ -r "/proc/$pid/environ" ] && tr '\\0' ' ' < "/proc/$pid/environ" 2>/dev/null | grep -q -F ${sh(markerEnv)}; }; then ` +
164
- `kill "$pid" 2>/dev/null || true; ` +
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
- // Marker match — covers lost PID files / processes without a readable pidfile.
171
- await exec(ssh, `pkill -f ${sh(markerEnv)} || true`);
172
- await exec(ssh, `pkill -f ${sh(markerJvm)} || true`);
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
- * After launch, wait briefly and confirm the PID is still alive otherwise
210
- * surface app.log and fail the deploy (nohup+echo $! alone always "succeeds").
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 pkill -f DEPLOYHUB_APP=… and the PID file both target only this env.
405
+ // so cmdline marker scans and the PID file both target only this env.
310
406
  await startScopedNohup(
311
407
  ssh,
312
408
  targetPath,