@akash-chowdhury-24/deployhub 2.0.20 → 2.0.22

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.
@@ -12,6 +12,7 @@ import {
12
12
  } from '../../utils/nginx.js';
13
13
  import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
14
14
  import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
15
+ import { extractGunicornTarget } from '../../utils/python-app-target.js';
15
16
 
16
17
  /** @type {Set<string>} */
17
18
  const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
@@ -171,8 +172,43 @@ export function createSshProvider(config, envName, env = process.env) {
171
172
  await exec(ssh, `pkill -f ${sh(markerJvm)} || true`);
172
173
  }
173
174
 
175
+ /**
176
+ * After starting a backend, wait briefly and confirm the PID file's process
177
+ * is still alive. Not a health check — only catches immediate crash.
178
+ * `port` is closed over from createSshProvider (settings.port / config.port).
179
+ *
180
+ * @param {import('node-ssh').NodeSSH} ssh
181
+ * @param {string} targetPath
182
+ * @param {string} [logFile] — defaults to targetPath/app.log
183
+ */
184
+ async function assertPidAliveAfterStart(ssh, targetPath, logFile) {
185
+ const pidFile = `${targetPath}/.deployhub.pid`;
186
+ const log = logFile || `${targetPath}/app.log`;
187
+ const verifyCmd =
188
+ `sleep 2; ` +
189
+ `pid="$(cat ${sh(pidFile)} 2>/dev/null | tr -cd '0-9')"; ` +
190
+ `if [ -z "$pid" ] || [ ! -d "/proc/$pid" ]; then ` +
191
+ `echo "DEPLOYHUB_PROCESS_DIED: process exited immediately after start (pidfile=${sh(pidFile)}). Last lines of ${sh(log)}:"; ` +
192
+ `tail -n 40 ${sh(log)} 2>/dev/null || echo "(no app.log)"; ` +
193
+ `exit 1; ` +
194
+ `fi`;
195
+
196
+ try {
197
+ await exec(ssh, verifyCmd);
198
+ } catch (err) {
199
+ const detail = err instanceof Error ? err.message : String(err);
200
+ throw new Error(
201
+ `Backend process for "${appName}" died immediately after start at ${targetPath}. ` +
202
+ `Check dependencies, entrypoint, and port ${port}.\n${detail}`
203
+ );
204
+ }
205
+ }
206
+
174
207
  /**
175
208
  * 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").
211
+ *
176
212
  * @param {import('node-ssh').NodeSSH} ssh
177
213
  * @param {string} targetPath
178
214
  * @param {string} command — command body after `nohup` (no trailing &)
@@ -184,6 +220,7 @@ export function createSshProvider(config, envName, env = process.env) {
184
220
  ssh,
185
221
  `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} nohup ${command} > app.log 2>&1 & echo $! > ${sh(pidFile)}`
186
222
  );
223
+ await assertPidAliveAfterStart(ssh, targetPath);
187
224
  }
188
225
 
189
226
  /**
@@ -234,13 +271,21 @@ export function createSshProvider(config, envName, env = process.env) {
234
271
  `uvicorn main:app --host 0.0.0.0 --port ${port}`
235
272
  );
236
273
  } else {
237
- // gunicorn --daemon writes its own PID; still set DEPLOYHUB_APP for pkill fallback.
238
- const appTarget =
274
+ // gunicorn --daemon writes the master PID to --pid (same .deployhub.pid).
275
+ // --error-logfile + --capture-output give us a log to surface on immediate death
276
+ // (daemonized stdout/stderr otherwise vanish).
277
+ const logFile = `${targetPath}/app.log`;
278
+ const fallbackTarget =
239
279
  framework === 'django' ? 'config.wsgi:application' : 'app:app';
280
+ const appTarget =
281
+ extractGunicornTarget(startCommand) || fallbackTarget;
240
282
  await exec(
241
283
  ssh,
242
- `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} --name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} --pid ${sh(pidFile)} --daemon`
284
+ `cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} ` +
285
+ `--name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} ` +
286
+ `--pid ${sh(pidFile)} --error-logfile ${sh(logFile)} --capture-output --daemon`
243
287
  );
288
+ await assertPidAliveAfterStart(ssh, targetPath, logFile);
244
289
  }
245
290
  return;
246
291
  }
@@ -1,5 +1,9 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import {
4
+ detectDjangoWsgiTarget,
5
+ detectFlaskAppTarget,
6
+ } from '../utils/python-app-target.js';
3
7
 
4
8
  /**
5
9
  * @typedef {Object} BackendDetectorResult
@@ -76,6 +80,7 @@ const FRAMEWORKS = {
76
80
  defaults: {
77
81
  language: 'python',
78
82
  buildCommand: null,
83
+ // Fallback only — getBackendInfo overrides via detectDjangoWsgiTarget(cwd)
79
84
  startCommand: 'gunicorn config.wsgi:application --bind 0.0.0.0:8000',
80
85
  buildOutput: '.',
81
86
  testCommand: 'python manage.py test',
@@ -87,6 +92,7 @@ const FRAMEWORKS = {
87
92
  defaults: {
88
93
  language: 'python',
89
94
  buildCommand: null,
95
+ // Fallback only — getBackendInfo overrides via detectFlaskAppTarget(cwd)
90
96
  startCommand: 'gunicorn app:app --bind 0.0.0.0:5000',
91
97
  buildOutput: '.',
92
98
  testCommand: 'pytest',
@@ -261,6 +267,7 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
261
267
  let buildCommand = def.defaults.buildCommand;
262
268
  let startCommand = def.defaults.startCommand;
263
269
  let testCommand = def.defaults.testCommand;
270
+ const port = def.defaults.port;
264
271
 
265
272
  if (def.defaults.language === 'node') {
266
273
  if (scripts.build) buildCommand = 'npm run build';
@@ -268,6 +275,14 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
268
275
  if (scripts.test) testCommand = 'npm test';
269
276
  }
270
277
 
278
+ if (framework === 'django') {
279
+ const target = detectDjangoWsgiTarget(cwd);
280
+ startCommand = `gunicorn ${target} --bind 0.0.0.0:${port}`;
281
+ } else if (framework === 'flask') {
282
+ const target = detectFlaskAppTarget(cwd);
283
+ startCommand = `gunicorn ${target} --bind 0.0.0.0:${port}`;
284
+ }
285
+
271
286
  return {
272
287
  projectType: 'backend',
273
288
  framework,
@@ -277,7 +292,7 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
277
292
  buildOutput: def.defaults.buildOutput,
278
293
  testCommand,
279
294
  hasDocker,
280
- port: def.defaults.port,
295
+ port,
281
296
  };
282
297
  }
283
298
 
@@ -6,10 +6,11 @@ function detect(cwd = process.cwd()) {
6
6
  }
7
7
 
8
8
  function getInfo(cwd = process.cwd()) {
9
+ // Composer install belongs in the install stage, not a compile/build step.
9
10
  return {
10
11
  framework: 'php',
11
- buildCommand: 'composer install --no-dev --optimize-autoloader',
12
- buildOutput: 'public',
12
+ buildCommand: null,
13
+ buildOutput: '.',
13
14
  hasDocker: fs.existsSync(path.join(cwd, 'Dockerfile')),
14
15
  };
15
16
  }
@@ -11,10 +11,11 @@ function detect(cwd = process.cwd()) {
11
11
 
12
12
  function getInfo(cwd = process.cwd()) {
13
13
  const hasDocker = fs.existsSync(path.join(cwd, 'Dockerfile'));
14
+ // Deps install belongs in the install stage (pip), not a compile/build step.
14
15
  return {
15
16
  framework: 'python',
16
- buildCommand: 'pip install -r requirements.txt',
17
- buildOutput: 'dist',
17
+ buildCommand: null,
18
+ buildOutput: '.',
18
19
  hasDocker,
19
20
  };
20
21
  }
@@ -319,9 +319,17 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
319
319
 
320
320
  let reused = false;
321
321
  if (!options.skipImageReuse) {
322
+ // Normal deploy: prefer pipeline image (exact tag, then :latest retag).
322
323
  reused = await ensureImageFromPipeline(imageRef);
324
+ } else if (await imageExistsLocally(imageRef)) {
325
+ // Rollback: never retag :latest onto an older buildId, but DO use the
326
+ // exact restored buildId image if it is already present locally.
327
+ log.info(`Using restored image ${imageRef} (skipImageReuse — no :latest retag)`);
328
+ reused = true;
323
329
  } else {
324
- log.info(`Skipping local image reuse — rebuilding ${imageRef} from artifact`);
330
+ log.info(
331
+ `Target image ${imageRef} not found locally — attempting rebuild from artifact`
332
+ );
325
333
  }
326
334
 
327
335
  let ranCompose = false;
@@ -0,0 +1,247 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ /** Directories to skip when walking for wsgi.py / Flask entrypoints. */
5
+ const SKIP_DIRS = new Set([
6
+ 'venv',
7
+ '.venv',
8
+ 'env',
9
+ '.env',
10
+ 'node_modules',
11
+ '__pycache__',
12
+ '.git',
13
+ '.tox',
14
+ 'site-packages',
15
+ 'dist',
16
+ 'build',
17
+ '.eggs',
18
+ ]);
19
+
20
+ /**
21
+ * @param {string} startCommand
22
+ * @returns {string|null} e.g. `myapp.wsgi:application` or `app:app`
23
+ */
24
+ export function extractGunicornTarget(startCommand) {
25
+ if (!startCommand || typeof startCommand !== 'string') return null;
26
+ const tokens = startCommand.trim().split(/\s+/);
27
+ const gIdx = tokens.findIndex((t) => t === 'gunicorn' || t.endsWith('/gunicorn'));
28
+ if (gIdx < 0) return null;
29
+ for (let i = gIdx + 1; i < tokens.length; i++) {
30
+ const t = tokens[i];
31
+ if (t.startsWith('-')) {
32
+ // skip flag and its value when it looks like `--bind 0.0.0.0:8000`
33
+ if (
34
+ t === '-b' ||
35
+ t === '--bind' ||
36
+ t === '-c' ||
37
+ t === '--config' ||
38
+ t === '-n' ||
39
+ t === '--name' ||
40
+ t === '-p' ||
41
+ t === '--pid' ||
42
+ t === '-w' ||
43
+ t === '--workers' ||
44
+ t === '--chdir' ||
45
+ t === '-e' ||
46
+ t === '--env' ||
47
+ t === '--error-logfile' ||
48
+ t === '--access-logfile' ||
49
+ t === '--log-file'
50
+ ) {
51
+ i += 1;
52
+ }
53
+ continue;
54
+ }
55
+ // gunicorn app target is module:callable
56
+ if (/^[A-Za-z_][\w.]*:[A-Za-z_]\w*$/.test(t)) {
57
+ return t;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Walk cwd for files named `wsgi.py`, skipping venvs etc.
65
+ * @param {string} cwd
66
+ * @param {number} [maxDepth]
67
+ * @returns {string[]} absolute paths
68
+ */
69
+ function findWsgiFiles(cwd, maxDepth = 4) {
70
+ /** @type {string[]} */
71
+ const found = [];
72
+
73
+ /**
74
+ * @param {string} dir
75
+ * @param {number} depth
76
+ */
77
+ function walk(dir, depth) {
78
+ if (depth > maxDepth || found.length >= 20) return;
79
+ let entries;
80
+ try {
81
+ entries = fs.readdirSync(dir, { withFileTypes: true });
82
+ } catch {
83
+ return;
84
+ }
85
+ for (const ent of entries) {
86
+ if (ent.name.startsWith('.') && ent.name !== '.venv') {
87
+ // skip hidden except we already skip .venv via SKIP_DIRS
88
+ if (ent.isDirectory()) continue;
89
+ }
90
+ const full = path.join(dir, ent.name);
91
+ if (ent.isDirectory()) {
92
+ if (SKIP_DIRS.has(ent.name)) continue;
93
+ walk(full, depth + 1);
94
+ } else if (ent.isFile() && ent.name === 'wsgi.py') {
95
+ found.push(full);
96
+ }
97
+ }
98
+ }
99
+
100
+ walk(cwd, 0);
101
+ return found;
102
+ }
103
+
104
+ /**
105
+ * Convert absolute wsgi.py path under cwd to gunicorn target `pkg.wsgi:application`.
106
+ * @param {string} cwd
107
+ * @param {string} wsgiAbs
108
+ * @returns {string|null}
109
+ */
110
+ export function wsgiPathToGunicornTarget(cwd, wsgiAbs) {
111
+ const rel = path.relative(cwd, wsgiAbs);
112
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
113
+ const noExt = rel.replace(/\.py$/i, '');
114
+ const parts = noExt.split(/[/\\]/).filter(Boolean);
115
+ if (parts.length === 0) return null;
116
+ // Invalid if any segment is not a Python identifier
117
+ if (parts.some((p) => !/^[A-Za-z_]\w*$/.test(p))) return null;
118
+ return `${parts.join('.')}:application`;
119
+ }
120
+
121
+ /**
122
+ * Package directory containing wsgi.py relative to cwd (e.g. `myapp`), or null if root wsgi.py.
123
+ * @param {string} cwd
124
+ * @returns {string|null} relative dir name, or '' for root-level wsgi.py, or null if none
125
+ */
126
+ export function detectDjangoWsgiPackageDir(cwd) {
127
+ const wsgiAbs = pickPreferredWsgiFile(cwd);
128
+ if (!wsgiAbs) return null;
129
+ const rel = path.relative(cwd, path.dirname(wsgiAbs));
130
+ if (!rel || rel === '.') return '';
131
+ // Only copy the top-level package segment (myapp/wsgi.py → myapp)
132
+ const top = rel.split(/[/\\]/)[0];
133
+ return top || '';
134
+ }
135
+
136
+ /**
137
+ * Prefer standard startproject layout (one level down from manage.py), then config/, then any.
138
+ * @param {string} cwd
139
+ * @returns {string|null} absolute path to wsgi.py
140
+ */
141
+ function pickPreferredWsgiFile(cwd) {
142
+ const files = findWsgiFiles(cwd);
143
+ if (files.length === 0) return null;
144
+
145
+ const hasManage = fs.existsSync(path.join(cwd, 'manage.py'));
146
+
147
+ // Prefer <pkg>/wsgi.py directly under cwd when manage.py exists (django-admin startproject)
148
+ if (hasManage) {
149
+ const oneLevel = files.filter((f) => {
150
+ const rel = path.relative(cwd, f);
151
+ const parts = rel.split(/[/\\]/);
152
+ return parts.length === 2 && parts[1] === 'wsgi.py';
153
+ });
154
+ if (oneLevel.length === 1) return oneLevel[0];
155
+ // Prefer config/wsgi.py when present among one-level candidates (cookiecutter)
156
+ const configOne = oneLevel.find((f) => path.basename(path.dirname(f)) === 'config');
157
+ if (configOne) return configOne;
158
+ if (oneLevel.length > 0) return oneLevel[0];
159
+ }
160
+
161
+ const configWsgi = files.find((f) => {
162
+ const rel = path.relative(cwd, f).replace(/\\/g, '/');
163
+ return rel === 'config/wsgi.py';
164
+ });
165
+ if (configWsgi) return configWsgi;
166
+
167
+ const rootWsgi = files.find((f) => path.dirname(f) === path.resolve(cwd));
168
+ if (rootWsgi) return rootWsgi;
169
+
170
+ return files[0];
171
+ }
172
+
173
+ /**
174
+ * @param {string} [cwd]
175
+ * @returns {string} gunicorn target, e.g. `myapp.wsgi:application`
176
+ */
177
+ export function detectDjangoWsgiTarget(cwd = process.cwd()) {
178
+ const wsgiAbs = pickPreferredWsgiFile(cwd);
179
+ if (!wsgiAbs) return 'config.wsgi:application';
180
+ return wsgiPathToGunicornTarget(cwd, wsgiAbs) || 'config.wsgi:application';
181
+ }
182
+
183
+ /**
184
+ * @param {string} filePath
185
+ * @returns {'app'|'application'|null}
186
+ */
187
+ function detectFlaskCallableName(filePath) {
188
+ let content;
189
+ try {
190
+ content = fs.readFileSync(filePath, 'utf-8');
191
+ } catch {
192
+ return null;
193
+ }
194
+ // Prefer explicit assignment / factory patterns for `app` then `application`
195
+ if (
196
+ /\bapp\s*=\s*/.test(content) ||
197
+ /\bcreate_app\s*\(/.test(content) ||
198
+ /\bFlask\s*\(/.test(content)
199
+ ) {
200
+ // If both exist, prefer `application` only when `app` is absent as assignment
201
+ if (/\bapp\s*=\s*/.test(content) || /\bFlask\s*\(/.test(content)) {
202
+ if (/\bapplication\s*=\s*/.test(content) && !/\bapp\s*=\s*/.test(content)) {
203
+ return 'application';
204
+ }
205
+ return 'app';
206
+ }
207
+ }
208
+ if (/\bapplication\s*=\s*/.test(content)) return 'application';
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * @param {string} [cwd]
214
+ * @returns {string} gunicorn target, e.g. `app:app`
215
+ */
216
+ export function detectFlaskAppTarget(cwd = process.cwd()) {
217
+ const candidates = ['app.py', 'wsgi.py', 'application.py'];
218
+ for (const name of candidates) {
219
+ const full = path.join(cwd, name);
220
+ if (!fs.existsSync(full)) continue;
221
+ const callable = detectFlaskCallableName(full);
222
+ if (callable) {
223
+ const mod = name.replace(/\.py$/i, '');
224
+ return `${mod}:${callable}`;
225
+ }
226
+ }
227
+ // Module package app/__init__.py or app/app.py
228
+ const pkgInit = path.join(cwd, 'app', '__init__.py');
229
+ if (fs.existsSync(pkgInit)) {
230
+ const callable = detectFlaskCallableName(pkgInit);
231
+ if (callable) return `app:${callable}`;
232
+ }
233
+ const pkgApp = path.join(cwd, 'app', 'app.py');
234
+ if (fs.existsSync(pkgApp)) {
235
+ const callable = detectFlaskCallableName(pkgApp);
236
+ if (callable) return `app.app:${callable}`;
237
+ }
238
+ return 'app:app';
239
+ }
240
+
241
+ export default {
242
+ extractGunicornTarget,
243
+ detectDjangoWsgiTarget,
244
+ detectFlaskAppTarget,
245
+ detectDjangoWsgiPackageDir,
246
+ wsgiPathToGunicornTarget,
247
+ };