@akash-chowdhury-24/deployhub 2.0.30 → 2.0.32
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 +3 -2
- package/package.json +1 -1
- package/src/artifact/engine.js +111 -83
- package/src/commands/doctor.js +230 -36
- package/src/core/config.js +10 -0
- package/src/core/environments.js +21 -6
- package/src/deployment/deployment-env.js +91 -25
- package/src/deployment/init-helpers.js +3 -1
- package/src/deployment/init-prompts.js +100 -6
- package/src/deployment/providers/docker.js +108 -5
- package/src/deployment/providers/kubernetes.js +3 -0
- package/src/deployment/providers/ssh.js +44 -92
- package/src/deployment/ssh-connection.js +149 -0
- package/src/detectors/frontend.detector.js +1 -1
- package/src/utils/credential-inventory.js +10 -0
- package/src/utils/docker-image-deploy.js +4 -0
- package/src/utils/docker-image.js +3 -3
- package/src/utils/docker-remote-mode.js +33 -0
- package/src/utils/docker-remote.js +214 -0
- package/src/utils/dockerfile.js +28 -9
- package/src/utils/github-actions.js +28 -6
- package/src/utils/php-fpm.js +3 -1
package/README.md
CHANGED
|
@@ -855,7 +855,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
855
855
|
1. Set `DOCKER_IMAGE_NAME` in `.env` (e.g. `myuser/myapp` for Docker Hub)
|
|
856
856
|
2. For private registries (or any push): set `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`
|
|
857
857
|
3. Leave `DOCKER_IMAGE_TAG` unset for a unique tag each build — set it only if you intentionally want a fixed tag
|
|
858
|
-
4. For remote
|
|
858
|
+
4. For a remote Linux host, choose **Remote Linux server via SSH** at init (`remote.mode: "ssh"`) and set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` — DeployHub runs `docker pull`/`run` over node-ssh. Use `DOCKER_HOST` only for the advanced raw CLI transport (`tcp://` or a custom `ssh://` setup you already manage)
|
|
859
859
|
5. Run `deployhub doctor`, then `git push origin main`
|
|
860
860
|
|
|
861
861
|
| Variable | Description | Example | Where to get it |
|
|
@@ -865,7 +865,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
865
865
|
| `DOCKER_REGISTRY_URL` | Registry URL (optional) | `https://ghcr.io` | Registry docs |
|
|
866
866
|
| `DOCKER_REGISTRY_USERNAME` | Registry user | `myuser` | Registry account |
|
|
867
867
|
| `DOCKER_REGISTRY_TOKEN` | Registry password/token | *(secret)* | Docker Hub / GHCR PAT |
|
|
868
|
-
| `DOCKER_HOST` |
|
|
868
|
+
| `DOCKER_HOST` | Advanced raw daemon URI (optional) | `tcp://host:2376` | Only if you manage TLS/ssh:// yourself |
|
|
869
|
+
| `SSH_HOST` / `SSH_USER` / `SSH_KEY_PATH` | Remote Linux via SSH (`remote.mode: ssh`) | `203.0.113.10` / `ubuntu` / `~/.ssh/key.pem` | Same names as EC2 |
|
|
869
870
|
|
|
870
871
|
### AWS EC2
|
|
871
872
|
|
package/package.json
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
getEnvSettings,
|
|
20
20
|
resolveDefaultEnvironmentName,
|
|
21
21
|
} from '../core/config.js';
|
|
22
|
-
import { detectDjangoWsgiPackageDir } from '../utils/python-app-target.js';
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* @param {string} cwd
|
|
@@ -183,66 +182,117 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
|
|
|
183
182
|
* @param {string} stagingDir
|
|
184
183
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
185
184
|
*/
|
|
185
|
+
/** Dirs/files that must never be packed into a backend artifact. */
|
|
186
|
+
const BACKEND_STAGE_EXCLUDE = new Set([
|
|
187
|
+
'node_modules',
|
|
188
|
+
'.git',
|
|
189
|
+
'.github',
|
|
190
|
+
'artifact',
|
|
191
|
+
'.deployhub',
|
|
192
|
+
'.deployhub-storage',
|
|
193
|
+
'.deployhub-restore',
|
|
194
|
+
'.deployhub-doctor-test',
|
|
195
|
+
'coverage',
|
|
196
|
+
'.venv',
|
|
197
|
+
'venv',
|
|
198
|
+
'__pycache__',
|
|
199
|
+
'vendor',
|
|
200
|
+
'.idea',
|
|
201
|
+
'.vscode',
|
|
202
|
+
'.nyc_output',
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Copy the backend project tree into staging, skipping install caches and VCS.
|
|
207
|
+
* Allowlists previously omitted root entrypoints (server.js, main.go, index.php)
|
|
208
|
+
* which made SSH `npm start` / binary start fail even though CI built fine.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} cwd
|
|
211
|
+
* @param {string} stagingDir
|
|
212
|
+
* @param {Set<string>} extraExclude
|
|
213
|
+
*/
|
|
214
|
+
async function copyBackendSourceTree(cwd, stagingDir, extraExclude = new Set()) {
|
|
215
|
+
const exclude = new Set([...BACKEND_STAGE_EXCLUDE, ...extraExclude]);
|
|
216
|
+
let entries = [];
|
|
217
|
+
try {
|
|
218
|
+
entries = await fs.readdir(cwd, { withFileTypes: true });
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const ent of entries) {
|
|
224
|
+
if (exclude.has(ent.name)) continue;
|
|
225
|
+
if (ent.name.startsWith('.env') && ent.name !== '.env.example') continue;
|
|
226
|
+
const src = path.join(cwd, ent.name);
|
|
227
|
+
const dest = path.join(stagingDir, ent.name);
|
|
228
|
+
if (ent.isDirectory()) {
|
|
229
|
+
await fs.copy(src, dest, {
|
|
230
|
+
filter: (p) => {
|
|
231
|
+
const base = path.basename(p);
|
|
232
|
+
if (exclude.has(base)) return false;
|
|
233
|
+
if (base === '__pycache__' || base === 'node_modules' || base === '.git') {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
await fs.copy(src, dest);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} stagingDir
|
|
247
|
+
*/
|
|
248
|
+
async function removeStagingDir(stagingDir) {
|
|
249
|
+
let lastErr;
|
|
250
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
251
|
+
try {
|
|
252
|
+
await fs.remove(stagingDir);
|
|
253
|
+
return;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
lastErr = err;
|
|
256
|
+
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const message = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
260
|
+
createLogger('artifact').warn(
|
|
261
|
+
`Could not remove staging dir after zip (artifact is still valid): ${message}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
186
265
|
async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
187
266
|
const settings = resolveBuildSettings(config);
|
|
188
267
|
const framework = settings.framework;
|
|
189
268
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
269
|
+
/** @type {Set<string>} */
|
|
270
|
+
const extraExclude = new Set();
|
|
271
|
+
// Compiled output is re-added selectively below (jars / publish / bin).
|
|
272
|
+
if (framework === 'spring' || framework === 'java') extraExclude.add('target');
|
|
273
|
+
if (framework === 'dotnet') {
|
|
274
|
+
extraExclude.add('bin');
|
|
275
|
+
extraExclude.add('obj');
|
|
194
276
|
}
|
|
195
277
|
|
|
278
|
+
await copyBackendSourceTree(cwd, stagingDir, extraExclude);
|
|
279
|
+
|
|
196
280
|
await copyKubernetesManifestsIfPresent(cwd, stagingDir);
|
|
197
281
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await copyIfExists(cwd, stagingDir, 'pyproject.toml');
|
|
208
|
-
await copyIfExists(cwd, stagingDir, 'Pipfile');
|
|
209
|
-
await copyIfExists(cwd, stagingDir, 'Pipfile.lock');
|
|
210
|
-
await copyIfExists(cwd, stagingDir, 'setup.py');
|
|
211
|
-
await copyIfExists(cwd, stagingDir, 'manage.py');
|
|
212
|
-
await copyIfExists(cwd, stagingDir, 'main.py');
|
|
213
|
-
await copyIfExists(cwd, stagingDir, 'app.py');
|
|
214
|
-
await copyIfExists(cwd, stagingDir, 'wsgi.py');
|
|
215
|
-
await copyIfExists(cwd, stagingDir, 'asgi.py');
|
|
216
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'app');
|
|
217
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'apps');
|
|
218
|
-
// Django project package often sits next to manage.py (e.g. config/, mysite/).
|
|
219
|
-
// `config/` is already copied above for all backends; also copy the detected
|
|
220
|
-
// package that owns wsgi.py when it is not config/app/apps.
|
|
221
|
-
if (framework === 'django') {
|
|
222
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'templates');
|
|
223
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'static');
|
|
224
|
-
const wsgiPkg = detectDjangoWsgiPackageDir(cwd);
|
|
225
|
-
if (
|
|
226
|
-
wsgiPkg &&
|
|
227
|
-
!['app', 'apps', 'config', 'templates', 'static', 'src'].includes(wsgiPkg)
|
|
228
|
-
) {
|
|
229
|
-
await copyDirectoryIfExists(cwd, stagingDir, wsgiPkg);
|
|
282
|
+
if (['laravel', 'symfony', 'php'].includes(framework)) {
|
|
283
|
+
// storage/logs is written by php-fpm (www-data). Packing live logs is useless
|
|
284
|
+
// and unpacking them on the next deploy is a common permission-denied unzip.
|
|
285
|
+
const logsDir = path.join(stagingDir, 'storage', 'logs');
|
|
286
|
+
if (await fs.pathExists(logsDir)) {
|
|
287
|
+
const logFiles = await fs.readdir(logsDir);
|
|
288
|
+
for (const f of logFiles) {
|
|
289
|
+
if (f === '.gitignore') continue;
|
|
290
|
+
await fs.remove(path.join(logsDir, f));
|
|
230
291
|
}
|
|
231
292
|
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
await copyIfExists(cwd, stagingDir, 'artisan');
|
|
236
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'app');
|
|
237
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'bootstrap');
|
|
238
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'public');
|
|
239
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'routes');
|
|
240
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'database');
|
|
241
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'resources');
|
|
242
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'storage');
|
|
243
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'bin');
|
|
244
|
-
} else if (framework === 'spring' || framework === 'java') {
|
|
245
|
-
await copyIfExists(cwd, stagingDir, 'pom.xml');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (framework === 'spring' || framework === 'java') {
|
|
246
296
|
const targetDir = path.join(cwd, 'target');
|
|
247
297
|
if (await fs.pathExists(targetDir)) {
|
|
248
298
|
await fs.ensureDir(path.join(stagingDir, 'target'));
|
|
@@ -251,37 +301,20 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
251
301
|
await fs.copy(path.join(targetDir, jar), path.join(stagingDir, 'target', jar));
|
|
252
302
|
}
|
|
253
303
|
}
|
|
254
|
-
} else if (framework === 'go') {
|
|
255
|
-
await copyIfExists(cwd, stagingDir, 'go.mod');
|
|
256
|
-
await copyIfExists(cwd, stagingDir, 'go.sum');
|
|
257
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'bin');
|
|
258
304
|
} else if (framework === 'dotnet') {
|
|
259
|
-
const files = await fs.readdir(cwd);
|
|
260
|
-
for (const f of files.filter((name) => name.endsWith('.csproj'))) {
|
|
261
|
-
await copyIfExists(cwd, stagingDir, f);
|
|
262
|
-
}
|
|
263
305
|
await copyDirectoryIfExists(cwd, stagingDir, settings.buildOutput || 'publish');
|
|
264
|
-
} else if (framework === 'rails' || framework === 'ruby') {
|
|
265
|
-
await copyIfExists(cwd, stagingDir, 'Gemfile');
|
|
266
|
-
await copyIfExists(cwd, stagingDir, 'Gemfile.lock');
|
|
267
|
-
await copyIfExists(cwd, stagingDir, 'config.ru');
|
|
268
|
-
await copyIfExists(cwd, stagingDir, 'Rakefile');
|
|
269
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'app');
|
|
270
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'bin');
|
|
271
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'lib');
|
|
272
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'db');
|
|
273
|
-
await copyDirectoryIfExists(cwd, stagingDir, 'public');
|
|
274
|
-
} else {
|
|
275
|
-
await copyIfExists(cwd, stagingDir, 'package.json');
|
|
276
|
-
await copyIfExists(cwd, stagingDir, 'requirements.txt');
|
|
277
306
|
}
|
|
278
307
|
|
|
279
|
-
//
|
|
280
|
-
// Skip '.' / 'src' — source is already staged above; never invent an empty dist/.
|
|
308
|
+
// Named build-output dir (nestjs dist/, etc.) when it exists and was not excluded.
|
|
281
309
|
if (settings.buildOutput && settings.buildOutput !== '.' && settings.buildOutput !== 'src') {
|
|
282
310
|
const built = path.join(cwd, settings.buildOutput);
|
|
283
|
-
|
|
284
|
-
|
|
311
|
+
const staged = path.join(stagingDir, settings.buildOutput);
|
|
312
|
+
if (
|
|
313
|
+
(await fs.pathExists(built)) &&
|
|
314
|
+
!(await fs.pathExists(staged)) &&
|
|
315
|
+
!['target', 'bin', 'publish'].includes(settings.buildOutput)
|
|
316
|
+
) {
|
|
317
|
+
await fs.copy(built, staged);
|
|
285
318
|
}
|
|
286
319
|
}
|
|
287
320
|
}
|
|
@@ -417,18 +450,13 @@ ${getArtifactReadmeFooter()}`;
|
|
|
417
450
|
const checksums = await generateChecksums(stagingDir);
|
|
418
451
|
const checksumContent = formatChecksums(checksums);
|
|
419
452
|
await fs.writeFile(path.join(artifactDir, 'checksums.txt'), checksumContent);
|
|
420
|
-
await fs.writeFile(path.join(stagingDir, 'checksums.txt'), checksumContent);
|
|
421
453
|
await fs.writeJson(
|
|
422
|
-
path.join(
|
|
454
|
+
path.join(artifactDir, 'deployment.json'),
|
|
423
455
|
{ targets: deployedTargets, deployedAt: timestamp },
|
|
424
456
|
{ spaces: 2 }
|
|
425
457
|
);
|
|
426
|
-
await fs.writeFile(
|
|
427
|
-
path.join(stagingDir, 'release-notes.md'),
|
|
428
|
-
await getReleaseNotes(cwd)
|
|
429
|
-
);
|
|
430
458
|
|
|
431
|
-
await
|
|
459
|
+
await removeStagingDir(stagingDir);
|
|
432
460
|
log.success(`Artifact created at ${artifactDir}`);
|
|
433
461
|
|
|
434
462
|
return { artifactDir, zipPath };
|
package/src/commands/doctor.js
CHANGED
|
@@ -31,6 +31,15 @@ import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
|
|
|
31
31
|
import { checkImagePullability } from '../utils/docker-image-deploy.js';
|
|
32
32
|
import { namespaceExists } from '../utils/kubernetes-namespace.js';
|
|
33
33
|
import { resolvePhpVersion } from '../utils/php-version.js';
|
|
34
|
+
import {
|
|
35
|
+
resolveDockerSshTarget,
|
|
36
|
+
probeRemoteDockerPs,
|
|
37
|
+
formatRemoteDockerSshFailure,
|
|
38
|
+
formatRemoteDockerNotInstalled,
|
|
39
|
+
formatRemoteDockerPermissionDenied,
|
|
40
|
+
formatRemoteDockerDaemonOk,
|
|
41
|
+
} from '../utils/docker-remote.js';
|
|
42
|
+
import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
|
|
34
43
|
import {
|
|
35
44
|
buildPhpFpmUnitListCommand,
|
|
36
45
|
formatPhpFpmMissingError,
|
|
@@ -183,14 +192,16 @@ export async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
183
192
|
|
|
184
193
|
/** @type {CheckResult[]} */
|
|
185
194
|
const checks = [];
|
|
186
|
-
const requiredKeys = getDeploymentEnvKeys(deployType, config);
|
|
195
|
+
const requiredKeys = getDeploymentEnvKeys(deployType, config, settings);
|
|
187
196
|
|
|
188
197
|
checks.push(
|
|
189
198
|
await runCheck(`${deployType} env vars`, async () => {
|
|
190
199
|
const missing = requiredKeys.filter((k) => {
|
|
191
200
|
if (k === 'SSH_KEY_PATH') {
|
|
192
|
-
return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY;
|
|
201
|
+
return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY && !settings.keyPath;
|
|
193
202
|
}
|
|
203
|
+
if (k === 'SSH_HOST') return !(settings.host || process.env.SSH_HOST);
|
|
204
|
+
if (k === 'SSH_USER') return !(settings.user || process.env.SSH_USER);
|
|
194
205
|
return !process.env[k];
|
|
195
206
|
});
|
|
196
207
|
if (missing.length > 0) {
|
|
@@ -447,22 +458,139 @@ export async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
447
458
|
}
|
|
448
459
|
|
|
449
460
|
if (deployType === 'docker') {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
461
|
+
const dockerRemoteMode = resolveDockerRemoteMode(settings, process.env);
|
|
462
|
+
|
|
463
|
+
if (dockerRemoteMode === 'ssh') {
|
|
464
|
+
const target = resolveDockerSshTarget(settings, process.env);
|
|
465
|
+
const host = target.host;
|
|
466
|
+
const user = target.user;
|
|
467
|
+
const keyPath = target.keyPath;
|
|
468
|
+
const sshPort = target.sshPort;
|
|
469
|
+
|
|
470
|
+
checks.push(
|
|
471
|
+
await runCheck('SSH key', async () => {
|
|
472
|
+
const result = await validateSshKeyForDoctor(
|
|
473
|
+
keyPath ? String(keyPath) : undefined,
|
|
474
|
+
process.env.SSH_KEY
|
|
475
|
+
);
|
|
476
|
+
return {
|
|
477
|
+
name: 'SSH key',
|
|
478
|
+
pass: result.ok,
|
|
479
|
+
message: result.message,
|
|
480
|
+
};
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
checks.push(
|
|
485
|
+
await runCheck('SSH host reachability', async () => {
|
|
486
|
+
if (!host) {
|
|
487
|
+
return {
|
|
488
|
+
name: 'SSH host reachability',
|
|
489
|
+
pass: false,
|
|
490
|
+
message: 'SSH_HOST is required — set it in .env to your server IP or hostname.',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
const result = await testSshHostReachability(String(host), sshPort);
|
|
494
|
+
return {
|
|
495
|
+
name: 'SSH host reachability',
|
|
496
|
+
pass: result.ok,
|
|
497
|
+
message: result.message,
|
|
498
|
+
};
|
|
499
|
+
})
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
checks.push(
|
|
503
|
+
await runCheck('Remote Docker daemon reachable', async () => {
|
|
504
|
+
const probe = await probeRemoteDockerPs(target);
|
|
505
|
+
if (!probe.sshOk) {
|
|
506
|
+
return {
|
|
507
|
+
name: 'Remote Docker daemon reachable',
|
|
508
|
+
pass: false,
|
|
509
|
+
message: formatRemoteDockerSshFailure(host, user),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (probe.kind === 'not-installed') {
|
|
513
|
+
return {
|
|
514
|
+
name: 'Remote Docker daemon reachable',
|
|
515
|
+
pass: false,
|
|
516
|
+
message: formatRemoteDockerNotInstalled(host, user),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (probe.kind === 'ok' || probe.kind === 'permission') {
|
|
520
|
+
return {
|
|
521
|
+
name: 'Remote Docker daemon reachable',
|
|
522
|
+
pass: true,
|
|
523
|
+
message: formatRemoteDockerDaemonOk(host, user),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
458
526
|
return {
|
|
459
|
-
name: 'Docker daemon',
|
|
527
|
+
name: 'Remote Docker daemon reachable',
|
|
460
528
|
pass: false,
|
|
461
|
-
message:
|
|
529
|
+
message:
|
|
530
|
+
probe.detail ||
|
|
531
|
+
`Remote Docker daemon is not reachable (${user}@${host}).`,
|
|
462
532
|
};
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
533
|
+
})
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
checks.push(
|
|
537
|
+
await runCheck('Remote Docker permission', async () => {
|
|
538
|
+
const probe = await probeRemoteDockerPs(target);
|
|
539
|
+
if (!probe.sshOk) {
|
|
540
|
+
return {
|
|
541
|
+
name: 'Remote Docker permission',
|
|
542
|
+
pass: false,
|
|
543
|
+
message: formatRemoteDockerSshFailure(host, user),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (probe.kind === 'permission') {
|
|
547
|
+
return {
|
|
548
|
+
name: 'Remote Docker permission',
|
|
549
|
+
pass: false,
|
|
550
|
+
message: formatRemoteDockerPermissionDenied(host, user),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (probe.kind === 'ok') {
|
|
554
|
+
return {
|
|
555
|
+
name: 'Remote Docker permission',
|
|
556
|
+
pass: true,
|
|
557
|
+
message: `SSH user '${user}' can access the Docker daemon on ${host}`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (probe.kind === 'not-installed') {
|
|
561
|
+
return {
|
|
562
|
+
name: 'Remote Docker permission',
|
|
563
|
+
pass: false,
|
|
564
|
+
message: formatRemoteDockerNotInstalled(host, user),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
name: 'Remote Docker permission',
|
|
569
|
+
pass: false,
|
|
570
|
+
message:
|
|
571
|
+
probe.detail ||
|
|
572
|
+
`Cannot verify Docker permissions for '${user}' on ${host}.`,
|
|
573
|
+
};
|
|
574
|
+
})
|
|
575
|
+
);
|
|
576
|
+
} else {
|
|
577
|
+
checks.push(
|
|
578
|
+
await runCheck('Docker daemon', async () => {
|
|
579
|
+
try {
|
|
580
|
+
const provider = getDeploymentProvider('docker', config, envName);
|
|
581
|
+
await provider.testConnection();
|
|
582
|
+
return { name: 'Docker daemon', pass: true, message: 'Docker daemon reachable' };
|
|
583
|
+
} catch (err) {
|
|
584
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
585
|
+
return {
|
|
586
|
+
name: 'Docker daemon',
|
|
587
|
+
pass: false,
|
|
588
|
+
message: `Docker not reachable — ${msg}. Install Docker or set DOCKER_HOST for a remote daemon.`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
);
|
|
593
|
+
}
|
|
466
594
|
}
|
|
467
595
|
|
|
468
596
|
if (deployType === 'kubernetes') {
|
|
@@ -644,7 +772,6 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
644
772
|
|
|
645
773
|
checks.push(
|
|
646
774
|
await runCheck('pip', async () => {
|
|
647
|
-
// Deploy runs `pip install -r requirements.txt`; accept pip3 or pip.
|
|
648
775
|
const result = await provider.runRemoteCheck(
|
|
649
776
|
'command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1'
|
|
650
777
|
);
|
|
@@ -661,24 +788,11 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
661
788
|
})
|
|
662
789
|
);
|
|
663
790
|
|
|
664
|
-
|
|
665
|
-
await runCheck('gunicorn', async () => {
|
|
666
|
-
const result = await provider.runRemoteCheck('which gunicorn || gunicorn --version');
|
|
667
|
-
if (result.pass) {
|
|
668
|
-
return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
|
|
669
|
-
}
|
|
670
|
-
return {
|
|
671
|
-
name: 'gunicorn',
|
|
672
|
-
pass: false,
|
|
673
|
-
message: 'not found — run: pip install gunicorn',
|
|
674
|
-
};
|
|
675
|
-
})
|
|
676
|
-
);
|
|
677
|
-
|
|
791
|
+
// FastAPI SSH starts uvicorn, not gunicorn. Requiring gunicorn here is a false failure.
|
|
678
792
|
if (framework === 'fastapi') {
|
|
679
793
|
checks.push(
|
|
680
794
|
await runCheck('uvicorn', async () => {
|
|
681
|
-
const result = await provider.runRemoteCheck('
|
|
795
|
+
const result = await provider.runRemoteCheck('command -v uvicorn >/dev/null 2>&1 || uvicorn --version');
|
|
682
796
|
if (result.pass) {
|
|
683
797
|
return { name: 'uvicorn', pass: true, message: 'uvicorn available' };
|
|
684
798
|
}
|
|
@@ -689,6 +803,20 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
689
803
|
};
|
|
690
804
|
})
|
|
691
805
|
);
|
|
806
|
+
} else {
|
|
807
|
+
checks.push(
|
|
808
|
+
await runCheck('gunicorn', async () => {
|
|
809
|
+
const result = await provider.runRemoteCheck('command -v gunicorn >/dev/null 2>&1 || gunicorn --version');
|
|
810
|
+
if (result.pass) {
|
|
811
|
+
return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
name: 'gunicorn',
|
|
815
|
+
pass: false,
|
|
816
|
+
message: 'not found — run: pip install gunicorn',
|
|
817
|
+
};
|
|
818
|
+
})
|
|
819
|
+
);
|
|
692
820
|
}
|
|
693
821
|
}
|
|
694
822
|
|
|
@@ -767,7 +895,7 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
767
895
|
}
|
|
768
896
|
|
|
769
897
|
const active = await provider.runRemoteCheck(
|
|
770
|
-
`systemctl is-active ${pick.unit}`
|
|
898
|
+
`sudo -n systemctl is-active ${pick.unit}`
|
|
771
899
|
);
|
|
772
900
|
if (active.pass && String(active.message).includes('active')) {
|
|
773
901
|
const note =
|
|
@@ -793,11 +921,28 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
793
921
|
|
|
794
922
|
checks.push(
|
|
795
923
|
await runCheck('nginx', async () => {
|
|
796
|
-
const
|
|
924
|
+
const installed = await provider.runRemoteCheck(
|
|
925
|
+
'command -v nginx >/dev/null 2>&1 && echo yes'
|
|
926
|
+
);
|
|
927
|
+
if (!installed.pass || !String(installed.message).includes('yes')) {
|
|
928
|
+
return {
|
|
929
|
+
name: 'nginx',
|
|
930
|
+
pass: false,
|
|
931
|
+
message:
|
|
932
|
+
'nginx not found on PATH — install it (Amazon Linux: sudo dnf install -y nginx; Ubuntu: sudo apt install nginx), then: sudo systemctl enable --now nginx',
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
const result = await provider.runRemoteCheck('sudo -n systemctl is-active nginx');
|
|
797
936
|
if (result.pass && result.message.includes('active')) {
|
|
798
937
|
return { name: 'nginx', pass: true, message: 'nginx running' };
|
|
799
938
|
}
|
|
800
|
-
return {
|
|
939
|
+
return {
|
|
940
|
+
name: 'nginx',
|
|
941
|
+
pass: false,
|
|
942
|
+
message:
|
|
943
|
+
`nginx is installed but not active (systemctl is-active: ${result.message}). ` +
|
|
944
|
+
'Start it with: sudo systemctl enable --now nginx',
|
|
945
|
+
};
|
|
801
946
|
})
|
|
802
947
|
);
|
|
803
948
|
}
|
|
@@ -823,6 +968,53 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
823
968
|
);
|
|
824
969
|
}
|
|
825
970
|
|
|
971
|
+
if (framework === 'dotnet') {
|
|
972
|
+
checks.push(
|
|
973
|
+
await runCheck('.NET runtime', async () => {
|
|
974
|
+
const result = await provider.runRemoteCheck('dotnet --version');
|
|
975
|
+
if (result.pass) {
|
|
976
|
+
return { name: '.NET runtime', pass: true, message: `dotnet ${result.message.split('\n')[0] || 'found'}` };
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
name: '.NET runtime',
|
|
980
|
+
pass: false,
|
|
981
|
+
message:
|
|
982
|
+
'dotnet not found on PATH — install the ASP.NET Core runtime on the server ' +
|
|
983
|
+
'(SSH deploy runs `dotnet <dll>` after extract)',
|
|
984
|
+
};
|
|
985
|
+
})
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (framework === 'rails' || framework === 'ruby') {
|
|
990
|
+
checks.push(
|
|
991
|
+
await runCheck('Ruby', async () => {
|
|
992
|
+
const result = await provider.runRemoteCheck('command -v ruby >/dev/null 2>&1 && ruby -v');
|
|
993
|
+
if (result.pass) {
|
|
994
|
+
return { name: 'Ruby', pass: true, message: result.message.split('\n')[0] || 'ruby found' };
|
|
995
|
+
}
|
|
996
|
+
return {
|
|
997
|
+
name: 'Ruby',
|
|
998
|
+
pass: false,
|
|
999
|
+
message: 'ruby not found on PATH — install Ruby 3.2+ on the server (SSH deploy runs bundle install)',
|
|
1000
|
+
};
|
|
1001
|
+
})
|
|
1002
|
+
);
|
|
1003
|
+
checks.push(
|
|
1004
|
+
await runCheck('Bundler', async () => {
|
|
1005
|
+
const result = await provider.runRemoteCheck('command -v bundle');
|
|
1006
|
+
if (result.pass) {
|
|
1007
|
+
return { name: 'Bundler', pass: true, message: 'bundle found on PATH' };
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
name: 'Bundler',
|
|
1011
|
+
pass: false,
|
|
1012
|
+
message: 'bundle not found on PATH — run: gem install bundler',
|
|
1013
|
+
};
|
|
1014
|
+
})
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
826
1018
|
return checks;
|
|
827
1019
|
}
|
|
828
1020
|
|
|
@@ -1078,7 +1270,9 @@ export function registerDoctorCommand(program) {
|
|
|
1078
1270
|
const method = getEnvMethod(env);
|
|
1079
1271
|
if (!method) continue;
|
|
1080
1272
|
// Local/.env uses unprefixed names; prefixed CI names are checked separately below.
|
|
1081
|
-
required.push(
|
|
1273
|
+
required.push(
|
|
1274
|
+
...getDeploymentSecretKeys(method, config, getEnvSettings(env))
|
|
1275
|
+
);
|
|
1082
1276
|
}
|
|
1083
1277
|
|
|
1084
1278
|
const unique = [...new Set(required)];
|
|
@@ -1115,7 +1309,7 @@ export function registerDoctorCommand(program) {
|
|
|
1115
1309
|
config,
|
|
1116
1310
|
config.environments
|
|
1117
1311
|
);
|
|
1118
|
-
const baseKeys = getDeploymentSecretKeys(method, config);
|
|
1312
|
+
const baseKeys = getDeploymentSecretKeys(method, config, getEnvSettings(env));
|
|
1119
1313
|
|
|
1120
1314
|
for (let i = 0; i < baseKeys.length; i++) {
|
|
1121
1315
|
const base = baseKeys[i];
|
package/src/core/config.js
CHANGED
|
@@ -54,6 +54,16 @@ const MethodConfigSchema = z
|
|
|
54
54
|
dockerImageName: z.string().optional(),
|
|
55
55
|
dockerRegistryUrl: z.string().optional(),
|
|
56
56
|
dockerHost: z.string().optional(),
|
|
57
|
+
/**
|
|
58
|
+
* Docker-method only. Kubernetes must ignore this — it deploys via kubectl,
|
|
59
|
+
* never a remote Docker daemon. `ssh` = node-ssh + remote docker CLI;
|
|
60
|
+
* `local` = this machine; `raw` = unmanaged DOCKER_HOST (ssh:// or tcp://).
|
|
61
|
+
*/
|
|
62
|
+
remote: z
|
|
63
|
+
.object({
|
|
64
|
+
mode: z.enum(['ssh', 'local', 'raw']),
|
|
65
|
+
})
|
|
66
|
+
.optional(),
|
|
57
67
|
healthCheckUrl: z.string().optional(),
|
|
58
68
|
appName: z.string().optional(),
|
|
59
69
|
framework: z.string().optional(),
|