@akash-chowdhury-24/deployhub 2.0.41 → 2.0.44
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 +133 -16
- package/package.json +1 -1
- package/src/commands/doctor.js +10 -0
- package/src/core/config.js +10 -0
- package/src/deployment/deployment-env.js +46 -11
- package/src/deployment/env-file.js +344 -0
- package/src/deployment/hooks.js +16 -6
- package/src/deployment/index.js +3 -0
- package/src/deployment/init-prompts.js +99 -6
- package/src/deployment/providers/docker.js +48 -2
- package/src/deployment/providers/ssh.js +75 -11
- package/src/deployment/ssh-connection.js +6 -2
- package/src/utils/docker-image-deploy.js +29 -8
- package/src/utils/docker-remote.js +7 -2
|
@@ -3,6 +3,11 @@ import { createLogger } from '../../logger/index.js';
|
|
|
3
3
|
import { getEnvSettings } from '../../core/config.js';
|
|
4
4
|
import { createSshExecSession } from '../ssh-connection.js';
|
|
5
5
|
import { runDeployHooks } from '../hooks.js';
|
|
6
|
+
import {
|
|
7
|
+
readManagedEnvFileContent,
|
|
8
|
+
resolveSshManagedEnvRemotePaths,
|
|
9
|
+
uploadManagedEnvFile,
|
|
10
|
+
} from '../env-file.js';
|
|
6
11
|
import {
|
|
7
12
|
getNginxSitesAvailablePath,
|
|
8
13
|
getNginxSitesEnabledPath,
|
|
@@ -75,7 +80,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
75
80
|
const { connect, exec, defaultExecTimeoutMs } = session;
|
|
76
81
|
const startStopTimeoutMs = Math.min(
|
|
77
82
|
defaultExecTimeoutMs,
|
|
78
|
-
Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS) ||
|
|
83
|
+
Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS || process.env.DEPLOYHUB_SSH_START_TIMEOUT_MS) ||
|
|
84
|
+
60_000
|
|
79
85
|
);
|
|
80
86
|
|
|
81
87
|
/**
|
|
@@ -253,9 +259,23 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
253
259
|
await assertPidAliveAfterStart(ssh, targetPath);
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
/**
|
|
263
|
+
* After the framework's package-manager install (and any built-in migrate
|
|
264
|
+
* / artisan steps), before stop/start. Same stage name on rollback so
|
|
265
|
+
* codegen re-runs against the restored files. No-op when the env has no
|
|
266
|
+
* `postInstall` hooks.
|
|
267
|
+
*
|
|
268
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
269
|
+
* @returns {Promise<import('node-ssh').NodeSSH>}
|
|
270
|
+
*/
|
|
271
|
+
async function runPostInstallHooks(ssh) {
|
|
272
|
+
return runDeployHooks({ session, ssh, settings, stage: 'postInstall' });
|
|
273
|
+
}
|
|
274
|
+
|
|
256
275
|
/**
|
|
257
276
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
258
277
|
* @param {string} targetPath
|
|
278
|
+
* @returns {Promise<import('node-ssh').NodeSSH>} possibly replaced after postInstall reconnect
|
|
259
279
|
*/
|
|
260
280
|
async function runBackendStartSequence(ssh, targetPath) {
|
|
261
281
|
const framework = resolveFramework();
|
|
@@ -265,6 +285,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
265
285
|
|
|
266
286
|
if (NODE_FRAMEWORKS.has(framework)) {
|
|
267
287
|
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
288
|
+
ssh = await runPostInstallHooks(ssh);
|
|
268
289
|
const start = startCommand || 'npm start';
|
|
269
290
|
if (start === 'npm start') {
|
|
270
291
|
await exec(
|
|
@@ -285,7 +306,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
285
306
|
);
|
|
286
307
|
}
|
|
287
308
|
await exec(ssh, 'pm2 save');
|
|
288
|
-
return;
|
|
309
|
+
return ssh;
|
|
289
310
|
}
|
|
290
311
|
|
|
291
312
|
if (PYTHON_FRAMEWORKS.has(framework)) {
|
|
@@ -293,6 +314,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
293
314
|
if (framework === 'django') {
|
|
294
315
|
await exec(ssh, `cd ${dir} && python manage.py migrate`);
|
|
295
316
|
}
|
|
317
|
+
ssh = await runPostInstallHooks(ssh);
|
|
296
318
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
297
319
|
if (framework === 'fastapi') {
|
|
298
320
|
await startScopedNohup(
|
|
@@ -318,7 +340,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
318
340
|
);
|
|
319
341
|
await assertPidAliveAfterStart(ssh, targetPath, logFile);
|
|
320
342
|
}
|
|
321
|
-
return;
|
|
343
|
+
return ssh;
|
|
322
344
|
}
|
|
323
345
|
|
|
324
346
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
@@ -338,14 +360,17 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
338
360
|
await exec(ssh, `cd ${dir} && php artisan config:cache`);
|
|
339
361
|
}
|
|
340
362
|
|
|
363
|
+
ssh = await runPostInstallHooks(ssh);
|
|
364
|
+
|
|
341
365
|
const fpmUnit = await resolveRemotePhpFpmUnit(ssh, phpVersion);
|
|
342
366
|
log.info(`Restarting PHP-FPM service: ${fpmUnit}`);
|
|
343
367
|
await exec(ssh, `sudo systemctl restart ${sh(fpmUnit)}`);
|
|
344
368
|
await reloadNginx(ssh);
|
|
345
|
-
return;
|
|
369
|
+
return ssh;
|
|
346
370
|
}
|
|
347
371
|
|
|
348
372
|
if (framework === 'spring' || framework === 'java') {
|
|
373
|
+
ssh = await runPostInstallHooks(ssh);
|
|
349
374
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
350
375
|
// -Ddeployhub.app= embeds the env-scoped identity in the JVM command line
|
|
351
376
|
// so cmdline marker scans and the PID file both target only this env.
|
|
@@ -354,18 +379,20 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
354
379
|
targetPath,
|
|
355
380
|
`java -Ddeployhub.app=${appName} -jar target/*.jar`
|
|
356
381
|
);
|
|
357
|
-
return;
|
|
382
|
+
return ssh;
|
|
358
383
|
}
|
|
359
384
|
|
|
360
385
|
if (framework === 'go') {
|
|
386
|
+
ssh = await runPostInstallHooks(ssh);
|
|
361
387
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
362
388
|
// Binary is always ./bin/app — must NOT pkill by appName alone (that never
|
|
363
389
|
// matched the process) and must NOT pkill a bare "app" (cross-env collision).
|
|
364
390
|
await startScopedNohup(ssh, targetPath, './bin/app');
|
|
365
|
-
return;
|
|
391
|
+
return ssh;
|
|
366
392
|
}
|
|
367
393
|
|
|
368
394
|
if (framework === 'dotnet') {
|
|
395
|
+
ssh = await runPostInstallHooks(ssh);
|
|
369
396
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
370
397
|
// Discover the published DLL at runtime — csproj name is not always App.dll
|
|
371
398
|
// (same class of bug as the Docker CMD ["dotnet","App.dll"] hardcode).
|
|
@@ -374,22 +401,25 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
374
401
|
targetPath,
|
|
375
402
|
`sh -c 'dll=$(ls -1 *.dll 2>/dev/null | head -n1); test -n "$dll" || { echo "No .dll found in $(pwd) for .NET start" >&2; exit 1; }; exec dotnet "$dll"'`
|
|
376
403
|
);
|
|
377
|
-
return;
|
|
404
|
+
return ssh;
|
|
378
405
|
}
|
|
379
406
|
|
|
380
407
|
if (framework === 'rails' || framework === 'ruby') {
|
|
381
408
|
await exec(ssh, `cd ${dir} && bundle install --deployment`);
|
|
409
|
+
ssh = await runPostInstallHooks(ssh);
|
|
382
410
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
383
411
|
await startScopedNohup(ssh, targetPath, `bundle exec puma -b tcp://0.0.0.0:${port}`);
|
|
384
|
-
return;
|
|
412
|
+
return ssh;
|
|
385
413
|
}
|
|
386
414
|
|
|
387
415
|
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
416
|
+
ssh = await runPostInstallHooks(ssh);
|
|
388
417
|
await exec(
|
|
389
418
|
ssh,
|
|
390
419
|
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
|
|
391
420
|
);
|
|
392
421
|
await exec(ssh, 'pm2 save');
|
|
422
|
+
return ssh;
|
|
393
423
|
}
|
|
394
424
|
|
|
395
425
|
/**
|
|
@@ -539,6 +569,33 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
539
569
|
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
|
|
540
570
|
}
|
|
541
571
|
|
|
572
|
+
/**
|
|
573
|
+
* DeployHub-managed .env: SFTP the current GitHub Secret onto the target.
|
|
574
|
+
* Overwrites every deploy, including rollback — .env is the current secret,
|
|
575
|
+
* not versioned per buildId. Runs after unzip/rsync and before
|
|
576
|
+
* runBackendStartSequence (install → postInstall → start), so a postInstall
|
|
577
|
+
* hook can `cd {deployPath} && set -a && . ./.env && set +a && prisma generate`.
|
|
578
|
+
* No-op when envFileSecretName is unset (hand-placed .env then survives
|
|
579
|
+
* unzip -o, same as before).
|
|
580
|
+
*
|
|
581
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
582
|
+
*/
|
|
583
|
+
async function uploadManagedEnvIfConfigured(ssh) {
|
|
584
|
+
const content = readManagedEnvFileContent(env, settings);
|
|
585
|
+
if (content == null) return;
|
|
586
|
+
const paths = resolveSshManagedEnvRemotePaths(config, settings);
|
|
587
|
+
for (const remotePath of paths) {
|
|
588
|
+
await uploadManagedEnvFile({
|
|
589
|
+
ssh,
|
|
590
|
+
session,
|
|
591
|
+
content,
|
|
592
|
+
remotePath,
|
|
593
|
+
user,
|
|
594
|
+
log,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
542
599
|
/**
|
|
543
600
|
* @param {string} artifactDir
|
|
544
601
|
* @param {{ isRollback?: boolean }} [options]
|
|
@@ -546,6 +603,9 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
546
603
|
async function deploy(artifactDir, options = {}) {
|
|
547
604
|
const isRollback = options.isRollback === true;
|
|
548
605
|
const preStage = isRollback ? 'rollback' : 'preDeploy';
|
|
606
|
+
// Fail fast (before SSH): GitHub Actions injects "" when the secret was
|
|
607
|
+
// never added or the name is misspelled. Must not unzip then write empty .env.
|
|
608
|
+
readManagedEnvFileContent(env, settings);
|
|
549
609
|
let ssh = await connect();
|
|
550
610
|
const projectType = config.projectType || 'frontend';
|
|
551
611
|
|
|
@@ -577,11 +637,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
577
637
|
`rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
|
|
578
638
|
);
|
|
579
639
|
|
|
640
|
+
await uploadManagedEnvIfConfigured(ssh);
|
|
641
|
+
|
|
580
642
|
if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
|
|
581
643
|
await setupNginx(ssh, frontendDeployPath);
|
|
582
644
|
}
|
|
583
645
|
|
|
584
|
-
await runBackendStartSequence(ssh, backendDeployPath);
|
|
646
|
+
ssh = await runBackendStartSequence(ssh, backendDeployPath);
|
|
585
647
|
} finally {
|
|
586
648
|
await exec(ssh, `rm -rf ${sh(remoteStaging)}`).catch(() => {});
|
|
587
649
|
}
|
|
@@ -589,15 +651,17 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
589
651
|
log.info(`Backend deploy path: ${deployPath}`);
|
|
590
652
|
ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
|
|
591
653
|
await extractToPath(ssh, remoteZip, deployPath);
|
|
592
|
-
await
|
|
654
|
+
await uploadManagedEnvIfConfigured(ssh);
|
|
655
|
+
ssh = await runBackendStartSequence(ssh, deployPath);
|
|
593
656
|
} else {
|
|
594
657
|
log.info(`Frontend deploy path: ${deployPath}`);
|
|
595
658
|
ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
|
|
596
659
|
await extractToPath(ssh, remoteZip, deployPath);
|
|
660
|
+
await uploadManagedEnvIfConfigured(ssh);
|
|
597
661
|
|
|
598
662
|
const framework = config.framework || 'react';
|
|
599
663
|
if (framework === 'nextjs') {
|
|
600
|
-
await runBackendStartSequence(ssh, deployPath);
|
|
664
|
+
ssh = await runBackendStartSequence(ssh, deployPath);
|
|
601
665
|
} else if (await remoteFileExists(ssh, `${deployPath}/nginx.conf`)) {
|
|
602
666
|
await setupNginx(ssh, deployPath);
|
|
603
667
|
}
|
|
@@ -28,8 +28,12 @@ export function createSshExecSession(opts) {
|
|
|
28
28
|
const log = opts.log || { info() {}, error() {} };
|
|
29
29
|
|
|
30
30
|
// Defense-in-depth: never let a stuck SSH channel hang CI indefinitely.
|
|
31
|
-
// Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms).
|
|
32
|
-
|
|
31
|
+
// Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms). Production default is 120s.
|
|
32
|
+
// Fall back to process.env so sparse credential overlays in tests/CI do not
|
|
33
|
+
// drop a process-level timeout override.
|
|
34
|
+
const defaultExecTimeoutMs =
|
|
35
|
+
Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS || process.env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) ||
|
|
36
|
+
120_000;
|
|
33
37
|
|
|
34
38
|
async function connect() {
|
|
35
39
|
if (!host || !user) {
|
|
@@ -109,15 +109,36 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
109
109
|
if (!hasRegistryCredentials()) return;
|
|
110
110
|
const registry = registryUrl || 'https://index.docker.io/v1/';
|
|
111
111
|
log.info('Logging in to container registry...');
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
112
|
+
const attempts = 3;
|
|
113
|
+
const loopback = /^(127\.0\.0\.1|localhost|\[::1\])/i.test(String(registry));
|
|
114
|
+
let lastErr;
|
|
115
|
+
for (let i = 1; i <= attempts; i++) {
|
|
116
|
+
try {
|
|
117
|
+
await execa(
|
|
118
|
+
'docker',
|
|
119
|
+
['login', registry, '-u', registryUser, '--password-stdin'],
|
|
120
|
+
{
|
|
121
|
+
input: registryToken,
|
|
122
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
|
+
env: getDockerEnv(),
|
|
124
|
+
timeout: 60_000,
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
lastErr = err;
|
|
130
|
+
const execErr = /** @type {{ message?: string, stderr?: string, stdout?: string }} */ (err);
|
|
131
|
+
const combined = `${execErr?.message || ''} ${execErr?.stderr || ''} ${execErr?.stdout || ''}`;
|
|
132
|
+
const transient =
|
|
133
|
+
/timeout|deadline exceeded|connection refused|tls handshake|i\/o timeout|eof|reset by peer|502|503/i.test(
|
|
134
|
+
combined
|
|
135
|
+
);
|
|
136
|
+
if (i === attempts || (!transient && !loopback)) throw err;
|
|
137
|
+
log.warn(`docker login attempt ${i}/${attempts} failed, retrying...`);
|
|
138
|
+
await new Promise((r) => setTimeout(r, 2000 * i));
|
|
119
139
|
}
|
|
120
|
-
|
|
140
|
+
}
|
|
141
|
+
throw lastErr;
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
/**
|
|
@@ -178,7 +178,7 @@ export async function probeRemoteDockerPs(target) {
|
|
|
178
178
|
* @param {string} imageRef
|
|
179
179
|
* @param {string} containerName
|
|
180
180
|
* @param {Record<string, string>} [runEnv]
|
|
181
|
-
* @param {{ publishPort?: number|null }} [options]
|
|
181
|
+
* @param {{ publishPort?: number|null, envFile?: string }} [options]
|
|
182
182
|
*/
|
|
183
183
|
export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}, options = {}) {
|
|
184
184
|
const image = shellQuote(imageRef);
|
|
@@ -192,12 +192,17 @@ export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {},
|
|
|
192
192
|
const publishPort = options.publishPort;
|
|
193
193
|
const pFlag =
|
|
194
194
|
publishPort != null ? `-p ${shellQuote(`${publishPort}:${publishPort}`)} ` : '';
|
|
195
|
+
const envFile = options.envFile;
|
|
196
|
+
const envFileFlag =
|
|
197
|
+
envFile != null && String(envFile).trim()
|
|
198
|
+
? `--env-file ${shellQuote(String(envFile).trim())} `
|
|
199
|
+
: '';
|
|
195
200
|
|
|
196
201
|
return {
|
|
197
202
|
stop: `docker stop ${name} 2>/dev/null || true`,
|
|
198
203
|
rm: `docker rm -f ${name} 2>/dev/null || true`,
|
|
199
204
|
pull: `docker pull ${image}`,
|
|
200
|
-
run: `docker run -d --rm --name ${name} ${pFlag}${envArg}${image}`,
|
|
205
|
+
run: `docker run -d --rm --name ${name} ${pFlag}${envFileFlag}${envArg}${image}`,
|
|
201
206
|
ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
|
|
202
207
|
info: 'docker info',
|
|
203
208
|
/**
|