@akash-chowdhury-24/deployhub 2.0.29 → 2.0.31
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 +2 -2
- package/package.json +1 -1
- package/src/artifact/engine.js +111 -83
- package/src/commands/doctor.js +183 -23
- package/src/deployment/deployment-env.js +1 -1
- package/src/deployment/init-helpers.js +3 -1
- package/src/deployment/providers/ssh.js +86 -7
- package/src/detectors/frontend.detector.js +1 -1
- package/src/utils/docker-image.js +3 -3
- package/src/utils/dockerfile.js +45 -16
- package/src/utils/github-actions.js +24 -28
- package/src/utils/php-fpm.js +135 -0
- package/src/utils/php-version.js +28 -0
package/README.md
CHANGED
|
@@ -651,8 +651,8 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
|
|
|
651
651
|
|
|
652
652
|
- **Detect:** `composer.json` with `laravel/framework` or `symfony/framework-bundle`.
|
|
653
653
|
- **Install:** Composer (on CI and server).
|
|
654
|
-
- **CI runtime:** Generated workflows install PHP via `shivammathur/setup-php@v2
|
|
655
|
-
- **Deploy
|
|
654
|
+
- **CI / Docker runtime:** Generated workflows install PHP via `shivammathur/setup-php@v2`, and PHP Dockerfiles use `php:{version}-cli-alpine` (verified tag pattern, e.g. `php:8.4-cli-alpine`). Both share `resolvePhpVersion()` — default **8.4**. Override with `"phpVersion": "8.3"` at the config root or under `"backend"` in `deployhub.config.json`, then regenerate the Dockerfile (delete the existing one so DeployHub can rewrite it) and run `deployhub sync-workflows` for CI.
|
|
655
|
+
- **Deploy (SSH):** Assumes host php-fpm + nginx are already installed and configured for the deploy path. Restarts `php{version}-fpm` when present (from `resolvePhpVersion()`, default **8.4** → `php8.4-fpm`), or generic `php-fpm` on Amazon Linux/RHEL. Runs remote `composer install --no-dev` (and Laravel migrate/config:cache). Config `startCommand` (e.g. `php artisan serve`) is **not** used on SSH — FPM+nginx only.
|
|
656
656
|
|
|
657
657
|
> ⚠️ **PHP-FPM deployments restart the FPM service for the ENTIRE host on every deploy.** If you run multiple DeployHub-managed environments on the same server, deploying ANY of them will briefly interrupt in-flight requests for ALL of them. For production use with multiple environments, either use separate hosts per environment, or set up per-environment PHP-FPM pools manually (not yet automated by DeployHub).
|
|
658
658
|
|
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
|
@@ -30,6 +30,16 @@ import {
|
|
|
30
30
|
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
|
+
import { resolvePhpVersion } from '../utils/php-version.js';
|
|
34
|
+
import {
|
|
35
|
+
buildPhpFpmUnitListCommand,
|
|
36
|
+
formatPhpFpmMissingError,
|
|
37
|
+
formatPhpFpmVersionMismatchError,
|
|
38
|
+
parsePhpFpmUnitList,
|
|
39
|
+
parsePhpMajorMinor,
|
|
40
|
+
pickPhpFpmUnitName,
|
|
41
|
+
preferredPhpFpmUnitName,
|
|
42
|
+
} from '../utils/php-fpm.js';
|
|
33
43
|
|
|
34
44
|
/**
|
|
35
45
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -634,7 +644,6 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
634
644
|
|
|
635
645
|
checks.push(
|
|
636
646
|
await runCheck('pip', async () => {
|
|
637
|
-
// Deploy runs `pip install -r requirements.txt`; accept pip3 or pip.
|
|
638
647
|
const result = await provider.runRemoteCheck(
|
|
639
648
|
'command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1'
|
|
640
649
|
);
|
|
@@ -651,24 +660,11 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
651
660
|
})
|
|
652
661
|
);
|
|
653
662
|
|
|
654
|
-
|
|
655
|
-
await runCheck('gunicorn', async () => {
|
|
656
|
-
const result = await provider.runRemoteCheck('which gunicorn || gunicorn --version');
|
|
657
|
-
if (result.pass) {
|
|
658
|
-
return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
|
|
659
|
-
}
|
|
660
|
-
return {
|
|
661
|
-
name: 'gunicorn',
|
|
662
|
-
pass: false,
|
|
663
|
-
message: 'not found — run: pip install gunicorn',
|
|
664
|
-
};
|
|
665
|
-
})
|
|
666
|
-
);
|
|
667
|
-
|
|
663
|
+
// FastAPI SSH starts uvicorn, not gunicorn. Requiring gunicorn here is a false failure.
|
|
668
664
|
if (framework === 'fastapi') {
|
|
669
665
|
checks.push(
|
|
670
666
|
await runCheck('uvicorn', async () => {
|
|
671
|
-
const result = await provider.runRemoteCheck('
|
|
667
|
+
const result = await provider.runRemoteCheck('command -v uvicorn >/dev/null 2>&1 || uvicorn --version');
|
|
672
668
|
if (result.pass) {
|
|
673
669
|
return { name: 'uvicorn', pass: true, message: 'uvicorn available' };
|
|
674
670
|
}
|
|
@@ -679,29 +675,146 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
679
675
|
};
|
|
680
676
|
})
|
|
681
677
|
);
|
|
678
|
+
} else {
|
|
679
|
+
checks.push(
|
|
680
|
+
await runCheck('gunicorn', async () => {
|
|
681
|
+
const result = await provider.runRemoteCheck('command -v gunicorn >/dev/null 2>&1 || gunicorn --version');
|
|
682
|
+
if (result.pass) {
|
|
683
|
+
return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
name: 'gunicorn',
|
|
687
|
+
pass: false,
|
|
688
|
+
message: 'not found — run: pip install gunicorn',
|
|
689
|
+
};
|
|
690
|
+
})
|
|
691
|
+
);
|
|
682
692
|
}
|
|
683
693
|
}
|
|
684
694
|
|
|
685
695
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
696
|
+
const phpVersion = resolvePhpVersion(config);
|
|
697
|
+
const preferredUnit = preferredPhpFpmUnitName(phpVersion);
|
|
698
|
+
|
|
699
|
+
checks.push(
|
|
700
|
+
await runCheck('PHP CLI', async () => {
|
|
701
|
+
const result = await provider.runRemoteCheck('command -v php >/dev/null 2>&1 && php -v');
|
|
702
|
+
if (!result.pass) {
|
|
703
|
+
return {
|
|
704
|
+
name: 'PHP CLI',
|
|
705
|
+
pass: false,
|
|
706
|
+
message:
|
|
707
|
+
`php not found on PATH — install PHP ${phpVersion} CLI on the server ` +
|
|
708
|
+
`(needed for composer / artisan during SSH deploy)`,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
const remoteMm = parsePhpMajorMinor(result.message);
|
|
712
|
+
if (remoteMm && remoteMm !== phpVersion) {
|
|
713
|
+
return {
|
|
714
|
+
name: 'PHP CLI',
|
|
715
|
+
pass: false,
|
|
716
|
+
message:
|
|
717
|
+
`php on server reports ${remoteMm} (from php -v), but this project expects ${phpVersion} ` +
|
|
718
|
+
`(phpVersion / backend.phpVersion / default). Install matching PHP or update deployhub.config.json.`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
name: 'PHP CLI',
|
|
723
|
+
pass: true,
|
|
724
|
+
message: remoteMm
|
|
725
|
+
? `php ${remoteMm} found on PATH (matches project)`
|
|
726
|
+
: `php found on PATH (${result.message.split('\n')[0] || 'ok'})`,
|
|
727
|
+
};
|
|
728
|
+
})
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
checks.push(
|
|
732
|
+
await runCheck('Composer', async () => {
|
|
733
|
+
const result = await provider.runRemoteCheck('command -v composer');
|
|
734
|
+
if (result.pass) {
|
|
735
|
+
return { name: 'Composer', pass: true, message: 'composer found on PATH' };
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
name: 'Composer',
|
|
739
|
+
pass: false,
|
|
740
|
+
message:
|
|
741
|
+
'composer not found on PATH — install Composer on the server ' +
|
|
742
|
+
'(SSH deploy runs `composer install --no-dev` after extract)',
|
|
743
|
+
};
|
|
744
|
+
})
|
|
745
|
+
);
|
|
746
|
+
|
|
686
747
|
checks.push(
|
|
687
748
|
await runCheck('php-fpm', async () => {
|
|
688
|
-
const
|
|
689
|
-
|
|
749
|
+
const listed = await provider.runRemoteCheck(buildPhpFpmUnitListCommand());
|
|
750
|
+
const units = parsePhpFpmUnitList(listed.message || '');
|
|
751
|
+
const pick = pickPhpFpmUnitName(units, phpVersion);
|
|
752
|
+
|
|
753
|
+
if (!pick) {
|
|
754
|
+
return {
|
|
755
|
+
name: 'php-fpm',
|
|
756
|
+
pass: false,
|
|
757
|
+
message: formatPhpFpmMissingError(phpVersion, units),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (pick.match === 'other-version') {
|
|
762
|
+
return {
|
|
763
|
+
name: 'php-fpm',
|
|
764
|
+
pass: false,
|
|
765
|
+
message: formatPhpFpmVersionMismatchError(phpVersion, pick.unit),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const active = await provider.runRemoteCheck(
|
|
770
|
+
`sudo -n systemctl is-active ${pick.unit}`
|
|
690
771
|
);
|
|
691
|
-
if (
|
|
692
|
-
|
|
772
|
+
if (active.pass && String(active.message).includes('active')) {
|
|
773
|
+
const note =
|
|
774
|
+
pick.match === 'generic'
|
|
775
|
+
? ` (generic php-fpm; preferred ${preferredUnit} not installed — OK on Amazon Linux/RHEL)`
|
|
776
|
+
: '';
|
|
777
|
+
return {
|
|
778
|
+
name: 'php-fpm',
|
|
779
|
+
pass: true,
|
|
780
|
+
message: `${pick.unit} running${note}`,
|
|
781
|
+
};
|
|
693
782
|
}
|
|
694
|
-
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
name: 'php-fpm',
|
|
786
|
+
pass: false,
|
|
787
|
+
message:
|
|
788
|
+
`${pick.unit} is installed but not active (systemctl is-active: ${active.message}). ` +
|
|
789
|
+
`Start it with: sudo systemctl enable --now ${pick.unit}`,
|
|
790
|
+
};
|
|
695
791
|
})
|
|
696
792
|
);
|
|
697
793
|
|
|
698
794
|
checks.push(
|
|
699
795
|
await runCheck('nginx', async () => {
|
|
700
|
-
const
|
|
796
|
+
const installed = await provider.runRemoteCheck(
|
|
797
|
+
'command -v nginx >/dev/null 2>&1 && echo yes'
|
|
798
|
+
);
|
|
799
|
+
if (!installed.pass || !String(installed.message).includes('yes')) {
|
|
800
|
+
return {
|
|
801
|
+
name: 'nginx',
|
|
802
|
+
pass: false,
|
|
803
|
+
message:
|
|
804
|
+
'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',
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const result = await provider.runRemoteCheck('sudo -n systemctl is-active nginx');
|
|
701
808
|
if (result.pass && result.message.includes('active')) {
|
|
702
809
|
return { name: 'nginx', pass: true, message: 'nginx running' };
|
|
703
810
|
}
|
|
704
|
-
return {
|
|
811
|
+
return {
|
|
812
|
+
name: 'nginx',
|
|
813
|
+
pass: false,
|
|
814
|
+
message:
|
|
815
|
+
`nginx is installed but not active (systemctl is-active: ${result.message}). ` +
|
|
816
|
+
'Start it with: sudo systemctl enable --now nginx',
|
|
817
|
+
};
|
|
705
818
|
})
|
|
706
819
|
);
|
|
707
820
|
}
|
|
@@ -727,6 +840,53 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
727
840
|
);
|
|
728
841
|
}
|
|
729
842
|
|
|
843
|
+
if (framework === 'dotnet') {
|
|
844
|
+
checks.push(
|
|
845
|
+
await runCheck('.NET runtime', async () => {
|
|
846
|
+
const result = await provider.runRemoteCheck('dotnet --version');
|
|
847
|
+
if (result.pass) {
|
|
848
|
+
return { name: '.NET runtime', pass: true, message: `dotnet ${result.message.split('\n')[0] || 'found'}` };
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
name: '.NET runtime',
|
|
852
|
+
pass: false,
|
|
853
|
+
message:
|
|
854
|
+
'dotnet not found on PATH — install the ASP.NET Core runtime on the server ' +
|
|
855
|
+
'(SSH deploy runs `dotnet <dll>` after extract)',
|
|
856
|
+
};
|
|
857
|
+
})
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (framework === 'rails' || framework === 'ruby') {
|
|
862
|
+
checks.push(
|
|
863
|
+
await runCheck('Ruby', async () => {
|
|
864
|
+
const result = await provider.runRemoteCheck('command -v ruby >/dev/null 2>&1 && ruby -v');
|
|
865
|
+
if (result.pass) {
|
|
866
|
+
return { name: 'Ruby', pass: true, message: result.message.split('\n')[0] || 'ruby found' };
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
name: 'Ruby',
|
|
870
|
+
pass: false,
|
|
871
|
+
message: 'ruby not found on PATH — install Ruby 3.2+ on the server (SSH deploy runs bundle install)',
|
|
872
|
+
};
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
checks.push(
|
|
876
|
+
await runCheck('Bundler', async () => {
|
|
877
|
+
const result = await provider.runRemoteCheck('command -v bundle');
|
|
878
|
+
if (result.pass) {
|
|
879
|
+
return { name: 'Bundler', pass: true, message: 'bundle found on PATH' };
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
name: 'Bundler',
|
|
883
|
+
pass: false,
|
|
884
|
+
message: 'bundle not found on PATH — run: gem install bundler',
|
|
885
|
+
};
|
|
886
|
+
})
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
730
890
|
return checks;
|
|
731
891
|
}
|
|
732
892
|
|
|
@@ -55,7 +55,7 @@ export const SSH_BACKEND_ENV_VARS = [
|
|
|
55
55
|
{
|
|
56
56
|
key: 'SSH_APP_NAME',
|
|
57
57
|
comment: [
|
|
58
|
-
'
|
|
58
|
+
'Env-scoped process identity: PM2 app name (Node), DEPLOYHUB_APP / PID markers (Python, Java, Go, .NET, Rails), and related resource names. Not the php-fpm systemd unit.',
|
|
59
59
|
],
|
|
60
60
|
example: 'my-api',
|
|
61
61
|
when: 'backend',
|
|
@@ -143,7 +143,9 @@ export async function validateSshKeyForDoctor(keyPath, sshKey) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
const mode = stat.mode & 0o777;
|
|
146
|
-
|
|
146
|
+
// Windows NTFS does not carry Unix 400/600; OpenSSH uses ACLs instead.
|
|
147
|
+
// A 666 stat here is not "world-readable" the way it is on Linux.
|
|
148
|
+
if (process.platform !== 'win32' && mode !== 0o400 && mode !== 0o600) {
|
|
147
149
|
return {
|
|
148
150
|
ok: false,
|
|
149
151
|
message: `SSH key permissions are ${mode.toString(8)} (should be 400 or 600) — run: chmod 600 ${resolved}`,
|
|
@@ -13,6 +13,15 @@ import {
|
|
|
13
13
|
import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
|
|
14
14
|
import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
|
|
15
15
|
import { extractGunicornTarget } from '../../utils/python-app-target.js';
|
|
16
|
+
import { resolvePhpVersion } from '../../utils/php-version.js';
|
|
17
|
+
import {
|
|
18
|
+
buildPhpFpmUnitListCommand,
|
|
19
|
+
formatPhpFpmMissingError,
|
|
20
|
+
formatPhpFpmVersionMismatchError,
|
|
21
|
+
parsePhpFpmUnitList,
|
|
22
|
+
pickPhpFpmUnitName,
|
|
23
|
+
preferredPhpFpmUnitName,
|
|
24
|
+
} from '../../utils/php-fpm.js';
|
|
16
25
|
|
|
17
26
|
/** @type {Set<string>} */
|
|
18
27
|
const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
|
|
@@ -389,13 +398,24 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
389
398
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
390
399
|
// PHP uses a host-wide `systemctl restart php*-fpm` (see README PHP warning).
|
|
391
400
|
// Per-env isolation is Nginx site name + deploy path — not automated FPM pools.
|
|
401
|
+
// startCommand (e.g. php artisan serve) is intentionally unused on SSH — FPM+nginx only.
|
|
402
|
+
const phpVersion = resolvePhpVersion(config);
|
|
403
|
+
const preferredUnit = preferredPhpFpmUnitName(phpVersion);
|
|
404
|
+
log.info(
|
|
405
|
+
`PHP backend detected — using php-fpm+nginx (prefer ${preferredUnit}, else php-fpm); ` +
|
|
406
|
+
`startCommand is not used for this method`
|
|
407
|
+
);
|
|
408
|
+
|
|
392
409
|
await exec(ssh, `cd ${dir} && composer install --no-dev`);
|
|
393
410
|
if (framework === 'laravel') {
|
|
394
411
|
await exec(ssh, `cd ${dir} && php artisan migrate --force`);
|
|
395
412
|
await exec(ssh, `cd ${dir} && php artisan config:cache`);
|
|
396
413
|
}
|
|
397
|
-
|
|
398
|
-
await
|
|
414
|
+
|
|
415
|
+
const fpmUnit = await resolveRemotePhpFpmUnit(ssh, phpVersion);
|
|
416
|
+
log.info(`Restarting PHP-FPM service: ${fpmUnit}`);
|
|
417
|
+
await exec(ssh, `sudo systemctl restart ${sh(fpmUnit)}`);
|
|
418
|
+
await reloadNginx(ssh);
|
|
399
419
|
return;
|
|
400
420
|
}
|
|
401
421
|
|
|
@@ -421,8 +441,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
421
441
|
|
|
422
442
|
if (framework === 'dotnet') {
|
|
423
443
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
424
|
-
|
|
425
|
-
|
|
444
|
+
// Discover the published DLL at runtime — csproj name is not always App.dll
|
|
445
|
+
// (same class of bug as the Docker CMD ["dotnet","App.dll"] hardcode).
|
|
446
|
+
await startScopedNohup(
|
|
447
|
+
ssh,
|
|
448
|
+
targetPath,
|
|
449
|
+
`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"'`
|
|
450
|
+
);
|
|
426
451
|
return;
|
|
427
452
|
}
|
|
428
453
|
|
|
@@ -441,6 +466,38 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
441
466
|
await exec(ssh, 'pm2 save');
|
|
442
467
|
}
|
|
443
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Resolve the php-fpm systemd unit on the remote host.
|
|
471
|
+
* Prefers php{version}-fpm (Debian/Ubuntu), then php-fpm (RHEL/Amazon Linux).
|
|
472
|
+
* Throws if nothing usable is installed — never restarts a guessed missing unit.
|
|
473
|
+
*
|
|
474
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
475
|
+
* @param {string} phpVersion
|
|
476
|
+
* @returns {Promise<string>}
|
|
477
|
+
*/
|
|
478
|
+
async function resolveRemotePhpFpmUnit(ssh, phpVersion) {
|
|
479
|
+
const listCmd = buildPhpFpmUnitListCommand();
|
|
480
|
+
const listed = await ssh.execCommand(listCmd);
|
|
481
|
+
const units = parsePhpFpmUnitList(listed.stdout || '');
|
|
482
|
+
const pick = pickPhpFpmUnitName(units, phpVersion);
|
|
483
|
+
|
|
484
|
+
if (pick?.match === 'exact' || pick?.match === 'generic') {
|
|
485
|
+
if (pick.match === 'generic') {
|
|
486
|
+
log.info(
|
|
487
|
+
`Preferred ${preferredPhpFpmUnitName(phpVersion)} not installed; ` +
|
|
488
|
+
`using generic php-fpm (typical on Amazon Linux/RHEL)`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return pick.unit;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (pick?.match === 'other-version') {
|
|
495
|
+
throw new Error(formatPhpFpmVersionMismatchError(phpVersion, pick.unit));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
throw new Error(formatPhpFpmMissingError(phpVersion, units));
|
|
499
|
+
}
|
|
500
|
+
|
|
444
501
|
/**
|
|
445
502
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
446
503
|
* @param {string} remotePath
|
|
@@ -524,13 +581,35 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
524
581
|
log.success('Nginx config tested and reloaded');
|
|
525
582
|
}
|
|
526
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Make an existing remote directory writable by the SSH user so the next
|
|
586
|
+
* unzip/rsync is not blocked by www-data ownership from php-fpm or nginx.
|
|
587
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
588
|
+
* @param {string} targetPath
|
|
589
|
+
*/
|
|
590
|
+
async function ensureWritableDeployDir(ssh, targetPath) {
|
|
591
|
+
const targetQ = sh(targetPath);
|
|
592
|
+
const userQ = sh(user);
|
|
593
|
+
await exec(ssh, `mkdir -p ${targetQ}`);
|
|
594
|
+
await exec(
|
|
595
|
+
ssh,
|
|
596
|
+
`if [ -d ${targetQ} ]; then ` +
|
|
597
|
+
`chmod -R u+w ${targetQ} 2>/dev/null || true; ` +
|
|
598
|
+
`if [ ! -w ${targetQ} ]; then ` +
|
|
599
|
+
`sudo chown -R ${userQ}:${userQ} ${targetQ} 2>/dev/null || true; ` +
|
|
600
|
+
`chmod -R u+w ${targetQ} 2>/dev/null || true; ` +
|
|
601
|
+
`fi; ` +
|
|
602
|
+
`fi`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
527
606
|
/**
|
|
528
607
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
529
608
|
* @param {string} remoteZip
|
|
530
609
|
* @param {string} targetPath
|
|
531
610
|
*/
|
|
532
611
|
async function extractToPath(ssh, remoteZip, targetPath) {
|
|
533
|
-
await
|
|
612
|
+
await ensureWritableDeployDir(ssh, targetPath);
|
|
534
613
|
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
|
|
535
614
|
}
|
|
536
615
|
|
|
@@ -554,13 +633,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
554
633
|
await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
|
|
555
634
|
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
|
|
556
635
|
|
|
557
|
-
await
|
|
636
|
+
await ensureWritableDeployDir(ssh, frontendDeployPath);
|
|
558
637
|
await exec(
|
|
559
638
|
ssh,
|
|
560
639
|
`rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
|
|
561
640
|
);
|
|
562
641
|
|
|
563
|
-
await
|
|
642
|
+
await ensureWritableDeployDir(ssh, backendDeployPath);
|
|
564
643
|
await exec(
|
|
565
644
|
ssh,
|
|
566
645
|
`rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
|
|
@@ -174,7 +174,7 @@ WORKDIR /app
|
|
|
174
174
|
COPY ${dir}/ .
|
|
175
175
|
EXPOSE ${port}
|
|
176
176
|
ENV ASPNETCORE_URLS=http://+:${port}
|
|
177
|
-
CMD ["
|
|
177
|
+
CMD ["sh", "-c", "dll=$(ls *.dll 2>/dev/null | head -n1); exec dotnet \\"$dll\\""]
|
|
178
178
|
`;
|
|
179
179
|
}
|
|
180
180
|
|
|
@@ -191,7 +191,7 @@ export function isFrontendStaticFramework(framework) {
|
|
|
191
191
|
* @param {string} framework
|
|
192
192
|
*/
|
|
193
193
|
export function isNodeBackendFramework(framework) {
|
|
194
|
-
return ['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework || '');
|
|
194
|
+
return ['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node'].includes(framework || '');
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
@@ -201,7 +201,7 @@ export function isNodeBackendFramework(framework) {
|
|
|
201
201
|
*/
|
|
202
202
|
export function isInterpretedBackendFramework(framework) {
|
|
203
203
|
return [
|
|
204
|
-
...['express', 'nestjs', 'fastify', 'koa', 'nextjs'],
|
|
204
|
+
...['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node'],
|
|
205
205
|
'fastapi',
|
|
206
206
|
'django',
|
|
207
207
|
'flask',
|
package/src/utils/dockerfile.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Dockerfile generation based on detected framework / project settings.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { resolvePhpVersion } from './php-version.js';
|
|
6
|
+
|
|
5
7
|
const GENERATED_HEADER =
|
|
6
8
|
'# Generated by DeployHub — review exposed port and start command before deploying.\n';
|
|
7
9
|
|
|
@@ -18,10 +20,11 @@ export function sanitizeDockerProjectName(projectName) {
|
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
21
|
-
* @returns {{ framework: string, buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number, projectType: string }}
|
|
23
|
+
* @returns {{ framework: string, buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number, projectType: string, phpVersion: string }}
|
|
22
24
|
*/
|
|
23
25
|
export function resolveDockerSettings(config) {
|
|
24
26
|
const projectType = config.projectType || 'frontend';
|
|
27
|
+
const phpVersion = resolvePhpVersion(config);
|
|
25
28
|
|
|
26
29
|
if (projectType === 'both' && config.backend) {
|
|
27
30
|
const framework = config.backend.framework || 'express';
|
|
@@ -33,6 +36,7 @@ export function resolveDockerSettings(config) {
|
|
|
33
36
|
startCommand: config.backend.startCommand ?? null,
|
|
34
37
|
port: config.backend.port || defaultPort(framework, projectType),
|
|
35
38
|
projectType,
|
|
39
|
+
phpVersion,
|
|
36
40
|
};
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -45,6 +49,7 @@ export function resolveDockerSettings(config) {
|
|
|
45
49
|
startCommand: config.startCommand ?? null,
|
|
46
50
|
port: config.port || defaultPort(framework, projectType),
|
|
47
51
|
projectType,
|
|
52
|
+
phpVersion,
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -71,6 +76,8 @@ function defaultBuildOutput(framework, projectType) {
|
|
|
71
76
|
* @param {string} projectType
|
|
72
77
|
*/
|
|
73
78
|
function defaultPort(framework, projectType) {
|
|
79
|
+
// Next.js is a Node server, not nginx static — even when projectType is frontend.
|
|
80
|
+
if (framework === 'nextjs') return 3000;
|
|
74
81
|
if (
|
|
75
82
|
projectType === 'frontend' ||
|
|
76
83
|
['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework)
|
|
@@ -239,9 +246,21 @@ export function generateDockerfile(config) {
|
|
|
239
246
|
* @param {{ buildCommand: string|null, buildOutput: string, port: number }} settings
|
|
240
247
|
*/
|
|
241
248
|
function generateFrontendStaticDockerfile(settings) {
|
|
242
|
-
const buildCmd = settings.buildCommand
|
|
249
|
+
const buildCmd = settings.buildCommand;
|
|
243
250
|
const output = settings.buildOutput || 'dist';
|
|
244
251
|
|
|
252
|
+
// Vanilla / prebuilt static trees have no compile step. Do not invent
|
|
253
|
+
// `npm run build` — that fails when package.json has no build script.
|
|
254
|
+
if (!buildCmd || String(buildCmd).trim() === '') {
|
|
255
|
+
const src = output === '.' ? '.' : output;
|
|
256
|
+
return `${GENERATED_HEADER}
|
|
257
|
+
FROM nginx:alpine
|
|
258
|
+
COPY ${src} /usr/share/nginx/html
|
|
259
|
+
EXPOSE 80
|
|
260
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
|
|
245
264
|
return `${GENERATED_HEADER}
|
|
246
265
|
FROM node:20-alpine AS build
|
|
247
266
|
WORKDIR /app
|
|
@@ -276,6 +295,7 @@ FROM node:20-alpine AS build
|
|
|
276
295
|
WORKDIR /app
|
|
277
296
|
COPY --from=deps /app/node_modules ./node_modules
|
|
278
297
|
COPY . .
|
|
298
|
+
RUN mkdir -p public
|
|
279
299
|
RUN ${buildCmd}
|
|
280
300
|
|
|
281
301
|
FROM node:20-alpine AS runner
|
|
@@ -448,19 +468,19 @@ function generatePythonDockerfile(settings) {
|
|
|
448
468
|
return `${GENERATED_HEADER}
|
|
449
469
|
FROM python:3.11-slim
|
|
450
470
|
WORKDIR /app
|
|
451
|
-
COPY requirements.txt* ./
|
|
452
|
-
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
|
|
453
471
|
COPY . .
|
|
472
|
+
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
|
|
454
473
|
EXPOSE ${port}
|
|
455
474
|
CMD ${JSON.stringify(startParts)}
|
|
456
475
|
`;
|
|
457
476
|
}
|
|
458
477
|
|
|
459
478
|
/**
|
|
460
|
-
* @param {{ port: number }} settings
|
|
479
|
+
* @param {{ port: number, phpVersion?: string }} settings
|
|
461
480
|
*/
|
|
462
481
|
function generateLaravelDockerfile(settings) {
|
|
463
482
|
const port = settings.port || 80;
|
|
483
|
+
const phpVersion = settings.phpVersion || resolvePhpVersion();
|
|
464
484
|
// Single-process HTTP for container/K8s fitness. SSH deploys still use host
|
|
465
485
|
// php-fpm + nginx; this image must listen on EXPOSE (FPM alone listens on 9000).
|
|
466
486
|
const cmd = JSON.stringify([
|
|
@@ -474,13 +494,15 @@ function generateLaravelDockerfile(settings) {
|
|
|
474
494
|
// Vendor stage copies only composer.json/lock for layer caching. Laravel's
|
|
475
495
|
// post-autoload-dump runs `php artisan package:discover`, which needs the
|
|
476
496
|
// full app — so install with --no-scripts here, then dump-autoload after COPY.
|
|
497
|
+
// --ignore-platform-reqs stays: composer:2 may ship a different PHP than the
|
|
498
|
+
// runtime image (phpVersion is applied only to the final stage below).
|
|
477
499
|
return `${GENERATED_HEADER}
|
|
478
500
|
FROM composer:2 AS vendor
|
|
479
501
|
WORKDIR /app
|
|
480
|
-
COPY composer.json
|
|
502
|
+
COPY composer.json ./
|
|
481
503
|
RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
|
|
482
504
|
|
|
483
|
-
FROM php
|
|
505
|
+
FROM php:${phpVersion}-cli-alpine
|
|
484
506
|
WORKDIR /var/www/html
|
|
485
507
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
486
508
|
RUN apk add --no-cache icu-libs libzip \\
|
|
@@ -497,10 +519,11 @@ CMD ${cmd}
|
|
|
497
519
|
}
|
|
498
520
|
|
|
499
521
|
/**
|
|
500
|
-
* @param {{ port: number }} settings
|
|
522
|
+
* @param {{ port: number, phpVersion?: string }} settings
|
|
501
523
|
*/
|
|
502
524
|
function generateSymfonyDockerfile(settings) {
|
|
503
525
|
const port = settings.port || 80;
|
|
526
|
+
const phpVersion = settings.phpVersion || resolvePhpVersion();
|
|
504
527
|
// Built-in server binds HTTP to EXPOSE — suitable for a single-container pod.
|
|
505
528
|
// Prefer a production reverse-proxy image if you outgrow this starter template.
|
|
506
529
|
const cmd = JSON.stringify(['php', '-S', `0.0.0.0:${port}`, '-t', 'public']);
|
|
@@ -510,10 +533,10 @@ function generateSymfonyDockerfile(settings) {
|
|
|
510
533
|
return `${GENERATED_HEADER}
|
|
511
534
|
FROM composer:2 AS vendor
|
|
512
535
|
WORKDIR /app
|
|
513
|
-
COPY composer.json
|
|
536
|
+
COPY composer.json ./
|
|
514
537
|
RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
|
|
515
538
|
|
|
516
|
-
FROM php
|
|
539
|
+
FROM php:${phpVersion}-cli-alpine
|
|
517
540
|
WORKDIR /var/www/html
|
|
518
541
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
519
542
|
RUN apk add --no-cache icu-libs libzip \\
|
|
@@ -531,10 +554,11 @@ CMD ${cmd}
|
|
|
531
554
|
/**
|
|
532
555
|
* Plain PHP (no Laravel/Symfony): single-process built-in server.
|
|
533
556
|
* Docroot prefers public/ at runtime when present; optional composer install.
|
|
534
|
-
* @param {{ port: number }} settings
|
|
557
|
+
* @param {{ port: number, phpVersion?: string }} settings
|
|
535
558
|
*/
|
|
536
559
|
function generatePhpDockerfile(settings) {
|
|
537
560
|
const port = settings.port || 80;
|
|
561
|
+
const phpVersion = settings.phpVersion || resolvePhpVersion();
|
|
538
562
|
const cmd = JSON.stringify([
|
|
539
563
|
'sh',
|
|
540
564
|
'-c',
|
|
@@ -542,7 +566,7 @@ function generatePhpDockerfile(settings) {
|
|
|
542
566
|
]);
|
|
543
567
|
|
|
544
568
|
return `${GENERATED_HEADER}
|
|
545
|
-
FROM php
|
|
569
|
+
FROM php:${phpVersion}-cli-alpine
|
|
546
570
|
WORKDIR /var/www/html
|
|
547
571
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
548
572
|
RUN apk add --no-cache icu-libs libzip \\
|
|
@@ -590,7 +614,7 @@ function generateGoDockerfile(settings) {
|
|
|
590
614
|
return `${GENERATED_HEADER}
|
|
591
615
|
FROM golang:1.22-alpine AS build
|
|
592
616
|
WORKDIR /app
|
|
593
|
-
COPY go.mod
|
|
617
|
+
COPY go.mod ./
|
|
594
618
|
RUN go mod download
|
|
595
619
|
COPY . .
|
|
596
620
|
RUN mkdir -p /app/bin && ${buildCmd}
|
|
@@ -612,6 +636,11 @@ function generateDotnetDockerfile(settings) {
|
|
|
612
636
|
const buildCmd = settings.buildCommand || 'dotnet publish -c Release -o publish';
|
|
613
637
|
const port = settings.port || 5000;
|
|
614
638
|
const output = settings.buildOutput || 'publish';
|
|
639
|
+
const startParts = [
|
|
640
|
+
'sh',
|
|
641
|
+
'-c',
|
|
642
|
+
'dll=$(ls *.dll 2>/dev/null | head -n1); exec dotnet "$dll"',
|
|
643
|
+
];
|
|
615
644
|
|
|
616
645
|
return `${GENERATED_HEADER}
|
|
617
646
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
|
@@ -626,7 +655,7 @@ WORKDIR /app
|
|
|
626
655
|
COPY --from=build /src/${output} .
|
|
627
656
|
EXPOSE ${port}
|
|
628
657
|
ENV ASPNETCORE_URLS=http://+:${port}
|
|
629
|
-
CMD
|
|
658
|
+
CMD ${JSON.stringify(startParts)}
|
|
630
659
|
`;
|
|
631
660
|
}
|
|
632
661
|
|
|
@@ -644,7 +673,7 @@ function generateRailsDockerfile(settings) {
|
|
|
644
673
|
FROM ruby:3.2-slim AS build
|
|
645
674
|
WORKDIR /app
|
|
646
675
|
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
|
|
647
|
-
COPY Gemfile
|
|
676
|
+
COPY Gemfile ./
|
|
648
677
|
RUN bundle config set --local without 'development test' && bundle install
|
|
649
678
|
COPY . .
|
|
650
679
|
RUN bundle exec rake assets:precompile || true
|
|
@@ -673,7 +702,7 @@ function generateRubyDockerfile(settings) {
|
|
|
673
702
|
FROM ruby:3.2-slim
|
|
674
703
|
WORKDIR /app
|
|
675
704
|
RUN apt-get update -qq && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
|
676
|
-
COPY Gemfile
|
|
705
|
+
COPY Gemfile ./
|
|
677
706
|
RUN bundle config set --local without 'development test' && bundle install
|
|
678
707
|
COPY . .
|
|
679
708
|
EXPOSE ${port}
|
|
@@ -22,9 +22,9 @@ import {
|
|
|
22
22
|
getEnabledEnvironmentNames,
|
|
23
23
|
isEnvEnabled,
|
|
24
24
|
} from '../core/environments.js';
|
|
25
|
+
import { resolvePhpVersion } from './php-version.js';
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
export const DEFAULT_PHP_VERSION = '8.4';
|
|
27
|
+
export { DEFAULT_PHP_VERSION, resolvePhpVersion } from './php-version.js';
|
|
28
28
|
|
|
29
29
|
/** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
|
|
30
30
|
|
|
@@ -271,27 +271,6 @@ function getGithubGitConfigStep() {
|
|
|
271
271
|
fi`;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
/**
|
|
275
|
-
* Resolve PHP version for CI setup-php.
|
|
276
|
-
* Order: `backend.phpVersion` → top-level `phpVersion` → {@link DEFAULT_PHP_VERSION} (`8.4`).
|
|
277
|
-
* Set either config key in deployhub.config.json to pin a different runtime
|
|
278
|
-
* (e.g. `"phpVersion": "8.3"` or `"backend": { "phpVersion": "8.3" }`).
|
|
279
|
-
*
|
|
280
|
-
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
281
|
-
* @returns {string}
|
|
282
|
-
*/
|
|
283
|
-
export function resolvePhpVersion(config) {
|
|
284
|
-
const fromBackend = config?.backend?.phpVersion;
|
|
285
|
-
if (typeof fromBackend === 'string' && fromBackend.trim()) {
|
|
286
|
-
return fromBackend.trim();
|
|
287
|
-
}
|
|
288
|
-
const fromRoot = config?.phpVersion;
|
|
289
|
-
if (typeof fromRoot === 'string' && fromRoot.trim()) {
|
|
290
|
-
return fromRoot.trim();
|
|
291
|
-
}
|
|
292
|
-
return DEFAULT_PHP_VERSION;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
274
|
/**
|
|
296
275
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
297
276
|
* @returns {boolean}
|
|
@@ -884,12 +863,10 @@ ${rollbackRun}
|
|
|
884
863
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
885
864
|
* @returns {string}
|
|
886
865
|
*/
|
|
887
|
-
function
|
|
888
|
-
if (!config) return 'npm install';
|
|
889
|
-
|
|
866
|
+
function getBackendInstallDepsCommand(config) {
|
|
890
867
|
const framework =
|
|
891
|
-
config
|
|
892
|
-
const language = config
|
|
868
|
+
config?.backend?.framework || config?.framework || 'express';
|
|
869
|
+
const language = config?.backend?.language || config?.language;
|
|
893
870
|
|
|
894
871
|
if (language === 'python' || ['fastapi', 'django', 'flask', 'python'].includes(framework)) {
|
|
895
872
|
return 'pip install -r requirements.txt';
|
|
@@ -912,6 +889,25 @@ function getInstallDepsCommand(config) {
|
|
|
912
889
|
return 'npm install';
|
|
913
890
|
}
|
|
914
891
|
|
|
892
|
+
/**
|
|
893
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
894
|
+
* @returns {string}
|
|
895
|
+
*/
|
|
896
|
+
function getInstallDepsCommand(config) {
|
|
897
|
+
if (!config) return 'npm install';
|
|
898
|
+
|
|
899
|
+
const projectType = config.projectType || 'frontend';
|
|
900
|
+
if (projectType === 'frontend') return 'npm install';
|
|
901
|
+
|
|
902
|
+
const backendCmd = getBackendInstallDepsCommand(config);
|
|
903
|
+
// Fullstack: frontend is always Node in this CLI. Backend install alone
|
|
904
|
+
// (composer/pip/…) leaves the SPA without node_modules and `npm run build` dies.
|
|
905
|
+
if (projectType === 'both' && backendCmd !== 'npm install') {
|
|
906
|
+
return `npm install && ${backendCmd}`;
|
|
907
|
+
}
|
|
908
|
+
return backendCmd;
|
|
909
|
+
}
|
|
910
|
+
|
|
915
911
|
/**
|
|
916
912
|
* Write deployhub.yml and deployhub-rollback.yml from the same secret/env helpers.
|
|
917
913
|
*
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHP-FPM systemd unit naming for SSH deploys / doctor.
|
|
3
|
+
* Debian/Ubuntu: php8.4-fpm; Amazon Linux/RHEL: often plain php-fpm.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolvePhpVersion } from './php-version.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} phpVersion — e.g. "8.4"
|
|
10
|
+
* @returns {string} — e.g. "php8.4-fpm"
|
|
11
|
+
*/
|
|
12
|
+
export function preferredPhpFpmUnitName(phpVersion) {
|
|
13
|
+
const v = String(phpVersion || '').trim();
|
|
14
|
+
return `php${v}-fpm`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shell command that lists installed php-fpm unit basenames (one per line).
|
|
19
|
+
* Uses unit-files so inactive-but-installed units still appear.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function buildPhpFpmUnitListCommand() {
|
|
23
|
+
// sudo -n: unprivileged SSH users cannot talk to systemd's private bus
|
|
24
|
+
// (Failed to connect to bus) inside containers and some hardened hosts.
|
|
25
|
+
return (
|
|
26
|
+
`sudo -n systemctl list-unit-files --type=service --no-legend ` +
|
|
27
|
+
`'php*-fpm.service' 'php-fpm.service' 2>/dev/null ` +
|
|
28
|
+
`| awk '{print $1}' | sed 's/\\.service$//' | sort -u`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} stdout
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function parsePhpFpmUnitList(stdout) {
|
|
37
|
+
if (!stdout || typeof stdout !== 'string') return [];
|
|
38
|
+
/** @type {Set<string>} */
|
|
39
|
+
const units = new Set();
|
|
40
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
41
|
+
const raw = line.trim();
|
|
42
|
+
if (!raw) continue;
|
|
43
|
+
// list-unit-files: "php8.4-fpm.service enabled" — first column only
|
|
44
|
+
const first = raw.split(/\s+/)[0] || '';
|
|
45
|
+
const name = first.replace(/\.service$/i, '');
|
|
46
|
+
if (!name) continue;
|
|
47
|
+
if (name === 'php-fpm' || /^php[\d.]+-fpm$/i.test(name)) {
|
|
48
|
+
units.add(name === 'php-fpm' ? 'php-fpm' : name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...units];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {{ unit: string, match: 'exact'|'generic'|'other-version' }} PhpFpmUnitPick
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Prefer versioned unit (Debian/Ubuntu), then generic php-fpm (RHEL/AL),
|
|
61
|
+
* else surface another versioned unit as a mismatch (do not silently use it
|
|
62
|
+
* for restart — caller decides whether to error).
|
|
63
|
+
*
|
|
64
|
+
* @param {string[]} availableUnits
|
|
65
|
+
* @param {string} phpVersion
|
|
66
|
+
* @returns {PhpFpmUnitPick | null}
|
|
67
|
+
*/
|
|
68
|
+
export function pickPhpFpmUnitName(availableUnits, phpVersion) {
|
|
69
|
+
const preferred = preferredPhpFpmUnitName(phpVersion);
|
|
70
|
+
const normalized = availableUnits.map((u) => u.trim()).filter(Boolean);
|
|
71
|
+
if (normalized.includes(preferred)) {
|
|
72
|
+
return { unit: preferred, match: 'exact' };
|
|
73
|
+
}
|
|
74
|
+
if (normalized.includes('php-fpm')) {
|
|
75
|
+
return { unit: 'php-fpm', match: 'generic' };
|
|
76
|
+
}
|
|
77
|
+
const other = normalized.find((u) => /^php[\d.]+-fpm$/i.test(u));
|
|
78
|
+
if (other) {
|
|
79
|
+
return { unit: other, match: 'other-version' };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function resolvePreferredPhpFpmUnit(config) {
|
|
89
|
+
return preferredPhpFpmUnitName(resolvePhpVersion(config));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} phpVersion
|
|
94
|
+
* @param {string[]} [foundUnits]
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function formatPhpFpmMissingError(phpVersion, foundUnits = []) {
|
|
98
|
+
const preferred = preferredPhpFpmUnitName(phpVersion);
|
|
99
|
+
const found =
|
|
100
|
+
foundUnits.length > 0 ? ` Found unit(s): ${foundUnits.join(', ')}.` : '';
|
|
101
|
+
return (
|
|
102
|
+
`No usable php-fpm systemd service for PHP ${phpVersion} ` +
|
|
103
|
+
`(looked for ${preferred} then php-fpm).${found} ` +
|
|
104
|
+
`Install PHP-FPM matching the project (Ubuntu/Debian: sudo apt install ${preferred}; ` +
|
|
105
|
+
`Amazon Linux/RHEL: often sudo yum install php-fpm) or set phpVersion / backend.phpVersion ` +
|
|
106
|
+
`in deployhub.config.json to match the server, then re-run.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} phpVersion
|
|
112
|
+
* @param {string} foundUnit
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function formatPhpFpmVersionMismatchError(phpVersion, foundUnit) {
|
|
116
|
+
const preferred = preferredPhpFpmUnitName(phpVersion);
|
|
117
|
+
return (
|
|
118
|
+
`Server has ${foundUnit} active/installed, but this project expects PHP ${phpVersion} ` +
|
|
119
|
+
`(service ${preferred}, or generic php-fpm on RHEL/Amazon Linux). ` +
|
|
120
|
+
`Install ${preferred} / matching PHP ${phpVersion}, or set phpVersion in deployhub.config.json ` +
|
|
121
|
+
`to match the server before deploy.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse `php -v` / `PHP 8.4.1 (cli)...` style output to major.minor.
|
|
127
|
+
* @param {string} phpVOutput
|
|
128
|
+
* @returns {string|null} e.g. "8.4"
|
|
129
|
+
*/
|
|
130
|
+
export function parsePhpMajorMinor(phpVOutput) {
|
|
131
|
+
if (!phpVOutput || typeof phpVOutput !== 'string') return null;
|
|
132
|
+
const m = phpVOutput.match(/\bPHP\s+(\d+)\.(\d+)/i);
|
|
133
|
+
if (!m) return null;
|
|
134
|
+
return `${m[1]}.${m[2]}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared PHP runtime version for CI (setup-php) and Docker base images.
|
|
3
|
+
* Keep Dockerfile FROM tags and GitHub Actions php-version in lockstep.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Default PHP for CI / Docker when config does not set phpVersion. */
|
|
7
|
+
export const DEFAULT_PHP_VERSION = '8.4';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve PHP version for setup-php and `php:{version}-cli-alpine` images.
|
|
11
|
+
* Order: `backend.phpVersion` → top-level `phpVersion` → {@link DEFAULT_PHP_VERSION} (`8.4`).
|
|
12
|
+
* Set either config key in deployhub.config.json to pin a different runtime
|
|
13
|
+
* (e.g. `"phpVersion": "8.3"` or `"backend": { "phpVersion": "8.3" }`).
|
|
14
|
+
*
|
|
15
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function resolvePhpVersion(config) {
|
|
19
|
+
const fromBackend = config?.backend?.phpVersion;
|
|
20
|
+
if (typeof fromBackend === 'string' && fromBackend.trim()) {
|
|
21
|
+
return fromBackend.trim();
|
|
22
|
+
}
|
|
23
|
+
const fromRoot = config?.phpVersion;
|
|
24
|
+
if (typeof fromRoot === 'string' && fromRoot.trim()) {
|
|
25
|
+
return fromRoot.trim();
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_PHP_VERSION;
|
|
28
|
+
}
|