@akash-chowdhury-24/deployhub 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/doctor.js +25 -0
- package/src/commands/init.js +22 -8
- package/src/deployment/deployment-env.js +137 -26
- package/src/deployment/init-prompts.js +22 -1
- package/src/deployment/providers/docker.js +5 -305
- package/src/deployment/providers/kubernetes.js +10 -0
- package/src/utils/docker-image-deploy.js +420 -0
- package/src/utils/github-actions.js +54 -10
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
formatDeployPathWriteFailure,
|
|
20
20
|
} from '../utils/shell-quote.js';
|
|
21
21
|
import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
|
|
22
|
+
import { checkImagePullability } from '../utils/docker-image-deploy.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -410,6 +411,30 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
410
411
|
}
|
|
411
412
|
})
|
|
412
413
|
);
|
|
414
|
+
|
|
415
|
+
checks.push(
|
|
416
|
+
await runCheck('Container image pullable', async () => {
|
|
417
|
+
const result = await checkImagePullability(config, process.env);
|
|
418
|
+
return {
|
|
419
|
+
name: 'Container image pullable',
|
|
420
|
+
pass: result.ok,
|
|
421
|
+
message: result.message,
|
|
422
|
+
};
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (deployType === 'docker' && process.env.DOCKER_REGISTRY_USERNAME && process.env.DOCKER_REGISTRY_TOKEN) {
|
|
428
|
+
checks.push(
|
|
429
|
+
await runCheck('Container image pullable', async () => {
|
|
430
|
+
const result = await checkImagePullability(config, process.env);
|
|
431
|
+
return {
|
|
432
|
+
name: 'Container image pullable',
|
|
433
|
+
pass: result.ok,
|
|
434
|
+
message: result.message,
|
|
435
|
+
};
|
|
436
|
+
})
|
|
437
|
+
);
|
|
413
438
|
}
|
|
414
439
|
|
|
415
440
|
if (deployType === 'ec2' && process.env.EC2_INSTANCE_ID) {
|
package/src/commands/init.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { getProjectVersion } from '../utils/version.js';
|
|
14
14
|
import {
|
|
15
15
|
writeWorkflowFile,
|
|
16
|
-
|
|
16
|
+
getGithubSecretsChecklist,
|
|
17
17
|
generateEnvExampleContent,
|
|
18
18
|
addDeployhubToPackageJson,
|
|
19
19
|
DEFAULT_NPM_CLI_SOURCE,
|
|
@@ -30,7 +30,10 @@ import {
|
|
|
30
30
|
getDockerEnvSecrets,
|
|
31
31
|
SSH_BASED,
|
|
32
32
|
} from '../deployment/init-prompts.js';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
printDeploymentNextSteps,
|
|
35
|
+
formatSecretChecklistLine,
|
|
36
|
+
} from '../deployment/deployment-env.js';
|
|
34
37
|
import { confirmValueIfContainsSpaces } from '../deployment/init-helpers.js';
|
|
35
38
|
|
|
36
39
|
const FRONTEND_CHOICES = [
|
|
@@ -498,23 +501,34 @@ export function registerInitCommand(program) {
|
|
|
498
501
|
);
|
|
499
502
|
await fs.writeFile(envExampleDest, envExampleContent);
|
|
500
503
|
|
|
501
|
-
const
|
|
504
|
+
const secretsChecklist = getGithubSecretsChecklist(
|
|
505
|
+
config.storage,
|
|
506
|
+
deploy,
|
|
507
|
+
environments,
|
|
508
|
+
config
|
|
509
|
+
);
|
|
502
510
|
|
|
503
511
|
console.log('');
|
|
504
512
|
if (primaryDeployType) {
|
|
505
513
|
console.log(chalk.green.bold(`✔ Config generated for ${primaryDeployType} deployment.`));
|
|
506
|
-
printDeploymentNextSteps(primaryDeployType,
|
|
514
|
+
printDeploymentNextSteps(primaryDeployType, secretsChecklist);
|
|
507
515
|
} else {
|
|
508
516
|
console.log(chalk.green.bold('✓ DeployHub initialized successfully!'));
|
|
509
517
|
console.log('');
|
|
510
518
|
console.log(chalk.bold('Next steps:'));
|
|
511
519
|
console.log(' 1. Copy .env.example to .env and fill in credentials');
|
|
512
|
-
if (
|
|
520
|
+
if (secretsChecklist.length > 0) {
|
|
513
521
|
console.log(' 2. Add these secrets to GitHub (Settings → Secrets):');
|
|
514
|
-
|
|
522
|
+
secretsChecklist.forEach((item) =>
|
|
523
|
+
console.log(` ${formatSecretChecklistLine(item)}`)
|
|
524
|
+
);
|
|
515
525
|
}
|
|
516
|
-
console.log(
|
|
517
|
-
|
|
526
|
+
console.log(
|
|
527
|
+
` ${secretsChecklist.length > 0 ? '3' : '2'}. Run ${chalk.cyan('deployhub doctor')} to verify your setup`
|
|
528
|
+
);
|
|
529
|
+
console.log(
|
|
530
|
+
` ${secretsChecklist.length > 0 ? '4' : '3'}. Push to main — GitHub Actions will run ${chalk.cyan('deployhub build')} automatically`
|
|
531
|
+
);
|
|
518
532
|
}
|
|
519
533
|
|
|
520
534
|
console.log('');
|
|
@@ -316,12 +316,11 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
316
316
|
},
|
|
317
317
|
{
|
|
318
318
|
key: 'DOCKER_IMAGE_NAME',
|
|
319
|
-
optionalReason: 'only required if your manifests need an image override at deploy time',
|
|
320
319
|
comment: [
|
|
321
|
-
'Container image to
|
|
320
|
+
'Container image to build, push, and deploy.',
|
|
321
|
+
'Kubernetes clusters pull from a registry — local-only Docker images will not work.',
|
|
322
322
|
],
|
|
323
323
|
example: 'ghcr.io/myorg/myapp',
|
|
324
|
-
when: 'optional',
|
|
325
324
|
},
|
|
326
325
|
{
|
|
327
326
|
key: 'DOCKER_IMAGE_TAG',
|
|
@@ -332,6 +331,30 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
332
331
|
example: 'latest',
|
|
333
332
|
when: 'optional',
|
|
334
333
|
},
|
|
334
|
+
{
|
|
335
|
+
key: 'DOCKER_REGISTRY_URL',
|
|
336
|
+
optionalReason: 'leave empty for Docker Hub',
|
|
337
|
+
comment: [
|
|
338
|
+
'Container registry URL. Leave empty for Docker Hub.',
|
|
339
|
+
'Examples: https://index.docker.io/v1/ | https://ghcr.io',
|
|
340
|
+
],
|
|
341
|
+
when: 'optional',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
key: 'DOCKER_REGISTRY_USERNAME',
|
|
345
|
+
comment: [
|
|
346
|
+
'Registry username — required to push so the cluster can pull your image.',
|
|
347
|
+
'Even public Docker Hub repos require authentication to push.',
|
|
348
|
+
],
|
|
349
|
+
example: 'myuser',
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
key: 'DOCKER_REGISTRY_TOKEN',
|
|
353
|
+
comment: [
|
|
354
|
+
'Registry password or personal access token — required to push so the cluster can pull.',
|
|
355
|
+
'Docker Hub: access token. GHCR: GitHub PAT with write:packages.',
|
|
356
|
+
],
|
|
357
|
+
},
|
|
335
358
|
{
|
|
336
359
|
key: 'KUBE_IMAGE_PULL_SECRET',
|
|
337
360
|
optionalReason: 'only required when pulling from a private container registry',
|
|
@@ -353,6 +376,8 @@ export const DEPLOYMENT_ENV_KEYS = Object.fromEntries(
|
|
|
353
376
|
);
|
|
354
377
|
|
|
355
378
|
/**
|
|
379
|
+
* Locally required env keys for doctor method-specific checks.
|
|
380
|
+
* Excludes optional and CI-only vars.
|
|
356
381
|
* @param {string} deployType
|
|
357
382
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
358
383
|
* @returns {string[]}
|
|
@@ -373,7 +398,16 @@ export function getDeploymentEnvKeys(deployType, config = null) {
|
|
|
373
398
|
}
|
|
374
399
|
|
|
375
400
|
/**
|
|
376
|
-
*
|
|
401
|
+
* Map a def key to the GitHub Actions secret name (SSH_KEY_PATH → SSH_KEY).
|
|
402
|
+
* @param {string} key
|
|
403
|
+
*/
|
|
404
|
+
function toGithubSecretKey(key) {
|
|
405
|
+
return key === 'SSH_KEY_PATH' ? 'SSH_KEY' : key;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Genuinely required secrets for doctor "Secrets" check and required checklist items.
|
|
410
|
+
* Includes CI-only vars (e.g. SSH_KEY) but NOT optional vars.
|
|
377
411
|
* @param {string} deployType
|
|
378
412
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
379
413
|
* @returns {string[]}
|
|
@@ -388,22 +422,93 @@ export function getDeploymentSecretKeys(deployType, config = null) {
|
|
|
388
422
|
|
|
389
423
|
for (const d of defs) {
|
|
390
424
|
if (d.when === 'backend' && !isBackend) continue;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
425
|
+
if (d.when === 'optional') continue;
|
|
426
|
+
keys.push(toGithubSecretKey(d.key));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return [...new Set(keys)];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Broad CI-wiring list for the GitHub Actions workflow generator.
|
|
434
|
+
* Includes required, CI-only, and optional keys so `${{ secrets.X }}` is
|
|
435
|
+
* available when the user sets optional secrets — empty secrets are fine.
|
|
436
|
+
* @param {string} deployType
|
|
437
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
438
|
+
* @returns {string[]}
|
|
439
|
+
*/
|
|
440
|
+
export function getDeploymentWorkflowSecretKeys(deployType, config = null) {
|
|
441
|
+
const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
|
|
442
|
+
const projectType = config?.projectType || 'frontend';
|
|
443
|
+
const isBackend = projectType === 'backend' || projectType === 'both';
|
|
444
|
+
|
|
445
|
+
/** @type {string[]} */
|
|
446
|
+
const keys = [];
|
|
447
|
+
|
|
448
|
+
for (const d of defs) {
|
|
449
|
+
if (d.when === 'backend' && !isBackend) continue;
|
|
450
|
+
keys.push(toGithubSecretKey(d.key));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return [...new Set(keys)];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* @typedef {{ key: string, required: boolean, note?: string }} SecretChecklistItem
|
|
458
|
+
*/
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Labeled GitHub Secrets checklist entries for a single deploy method.
|
|
462
|
+
* @param {string} deployType
|
|
463
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
464
|
+
* @returns {SecretChecklistItem[]}
|
|
465
|
+
*/
|
|
466
|
+
export function getDeploymentSecretChecklistItems(deployType, config = null) {
|
|
467
|
+
const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
|
|
468
|
+
const projectType = config?.projectType || 'frontend';
|
|
469
|
+
const isBackend = projectType === 'backend' || projectType === 'both';
|
|
470
|
+
|
|
471
|
+
/** @type {Map<string, SecretChecklistItem>} */
|
|
472
|
+
const byKey = new Map();
|
|
473
|
+
|
|
474
|
+
for (const d of defs) {
|
|
475
|
+
if (d.when === 'backend' && !isBackend) continue;
|
|
476
|
+
|
|
477
|
+
const key = toGithubSecretKey(d.key);
|
|
478
|
+
const required = d.when !== 'optional';
|
|
479
|
+
const note =
|
|
480
|
+
d.when === 'optional'
|
|
481
|
+
? d.optionalReason
|
|
482
|
+
: d.when === 'ci'
|
|
483
|
+
? d.optionalReason || 'required for GitHub Actions CI (paste private key contents)'
|
|
484
|
+
: undefined;
|
|
485
|
+
|
|
486
|
+
const existing = byKey.get(key);
|
|
487
|
+
if (existing) {
|
|
488
|
+
// Prefer required if any def for this key is required
|
|
489
|
+
if (required && !existing.required) {
|
|
490
|
+
byKey.set(key, { key, required: true, note });
|
|
396
491
|
}
|
|
397
492
|
continue;
|
|
398
493
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
keys.push(d.key);
|
|
494
|
+
|
|
495
|
+
byKey.set(key, { key, required, note });
|
|
404
496
|
}
|
|
405
497
|
|
|
406
|
-
return
|
|
498
|
+
return Array.from(byKey.values());
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Format a checklist item for console output.
|
|
503
|
+
* @param {SecretChecklistItem} item
|
|
504
|
+
*/
|
|
505
|
+
export function formatSecretChecklistLine(item) {
|
|
506
|
+
if (item.required) {
|
|
507
|
+
const note = item.note ? ` — ${item.note}` : '';
|
|
508
|
+
return `• ${item.key} (required${note})`;
|
|
509
|
+
}
|
|
510
|
+
const note = item.note ? ` — ${item.note}` : '';
|
|
511
|
+
return `• ${item.key} (optional${note})`;
|
|
407
512
|
}
|
|
408
513
|
|
|
409
514
|
/**
|
|
@@ -449,6 +554,7 @@ export function generateDeploymentEnvSection(
|
|
|
449
554
|
return lines.join('\n').trimEnd();
|
|
450
555
|
}
|
|
451
556
|
|
|
557
|
+
|
|
452
558
|
/**
|
|
453
559
|
* @param {string} key
|
|
454
560
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
@@ -527,8 +633,7 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
527
633
|
'Copy .env.example to .env and set DOCKER_IMAGE_NAME (required — e.g. myuser/myapp).',
|
|
528
634
|
'Optional in .env: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
|
|
529
635
|
'If using a private registry: also set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
|
|
530
|
-
'Add GitHub Secrets (Settings → Secrets and variables → Actions)
|
|
531
|
-
'Also add as GitHub Secrets if set locally: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_REGISTRY_USERNAME, DOCKER_REGISTRY_TOKEN, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
|
|
636
|
+
'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions). Local .env is NOT used by GitHub Actions — doctor only checks your machine.',
|
|
532
637
|
'Run deployhub doctor to verify Docker is reachable.',
|
|
533
638
|
'git push origin main to trigger your first deployment.',
|
|
534
639
|
],
|
|
@@ -601,30 +706,36 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
601
706
|
before: [
|
|
602
707
|
'An existing Kubernetes cluster (DeployHub does not provision clusters).',
|
|
603
708
|
'kubectl installed and configured (kubectl cluster-info works).',
|
|
709
|
+
'A container registry account — clusters pull images from a registry, not your local Docker daemon.',
|
|
710
|
+
'Registry credentials (username + token) to push your image so the cluster can pull it.',
|
|
604
711
|
'Kubernetes manifests (Deployment, Service, etc.) in your repo or artifact.',
|
|
605
712
|
'Cluster access from CI: kubeconfig or cloud-specific auth for GitHub Actions.',
|
|
606
713
|
],
|
|
607
714
|
automates: [
|
|
608
715
|
'Lists available kubectl contexts during init so you pick from a menu.',
|
|
609
716
|
'Auto-detects ~/.kube/config if present.',
|
|
610
|
-
'Generates complete .env.example for kubeconfig, context, and
|
|
717
|
+
'Generates complete .env.example for kubeconfig, context, namespace, and registry settings.',
|
|
611
718
|
'Tests cluster connectivity during init.',
|
|
719
|
+
'Builds and pushes your container image during deployhub build/deploy (pipeline docker stage).',
|
|
612
720
|
],
|
|
613
721
|
after: [
|
|
614
722
|
'Ensure your kubeconfig context points to the correct cluster.',
|
|
615
723
|
'Create namespace if needed: kubectl create namespace YOUR_NAMESPACE',
|
|
616
|
-
'
|
|
617
|
-
'
|
|
618
|
-
'
|
|
724
|
+
'Copy .env.example to .env and set DOCKER_IMAGE_NAME, DOCKER_REGISTRY_USERNAME, and DOCKER_REGISTRY_TOKEN.',
|
|
725
|
+
'Skipping registry credentials will very likely cause ImagePullBackOff — the cluster cannot see local Docker images.',
|
|
726
|
+
'For private registries: also create kubectl create secret docker-registry ... and set KUBE_IMAGE_PULL_SECRET.',
|
|
727
|
+
'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions).',
|
|
728
|
+
'Run deployhub doctor to verify cluster access and that your image is pullable.',
|
|
729
|
+
'git push origin main to trigger your first deployment.',
|
|
619
730
|
],
|
|
620
731
|
},
|
|
621
732
|
};
|
|
622
733
|
|
|
623
734
|
/**
|
|
624
735
|
* @param {string} deployType
|
|
625
|
-
* @param {
|
|
736
|
+
* @param {SecretChecklistItem[]} [checklist]
|
|
626
737
|
*/
|
|
627
|
-
export function printDeploymentNextSteps(deployType,
|
|
738
|
+
export function printDeploymentNextSteps(deployType, checklist = []) {
|
|
628
739
|
const guide = DEPLOYMENT_GUIDE[deployType];
|
|
629
740
|
if (!guide) return;
|
|
630
741
|
|
|
@@ -635,10 +746,10 @@ export function printDeploymentNextSteps(deployType, extraSecrets = []) {
|
|
|
635
746
|
console.log(` ${i + 1}. ${step}`);
|
|
636
747
|
});
|
|
637
748
|
|
|
638
|
-
if (
|
|
749
|
+
if (checklist.length > 0) {
|
|
639
750
|
console.log('\n GitHub Secrets to add (Settings → Secrets and variables → Actions):');
|
|
640
|
-
for (const
|
|
641
|
-
console.log(`
|
|
751
|
+
for (const item of checklist) {
|
|
752
|
+
console.log(` ${formatSecretChecklistLine(item)}`);
|
|
642
753
|
}
|
|
643
754
|
}
|
|
644
755
|
}
|
|
@@ -94,6 +94,23 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
|
|
|
94
94
|
message: 'Container image name (e.g. ghcr.io/myorg/myapp):',
|
|
95
95
|
default: projectName,
|
|
96
96
|
},
|
|
97
|
+
{
|
|
98
|
+
type: 'input',
|
|
99
|
+
name: 'dockerRegistryUrl',
|
|
100
|
+
message: 'Registry URL (leave empty for Docker Hub):',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'input',
|
|
104
|
+
name: 'dockerRegistryUsername',
|
|
105
|
+
message:
|
|
106
|
+
'Registry username (required — needed to push your image so the cluster can pull it):',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: 'password',
|
|
110
|
+
name: 'dockerRegistryToken',
|
|
111
|
+
message:
|
|
112
|
+
'Registry token/password (required — needed to push your image so the cluster can pull it):',
|
|
113
|
+
},
|
|
97
114
|
{
|
|
98
115
|
type: 'input',
|
|
99
116
|
name: 'healthUrl',
|
|
@@ -115,6 +132,9 @@ async function promptKubernetesDeployment(base, projectName, projectType) {
|
|
|
115
132
|
kubeContext: kubeAnswers.kubeContext,
|
|
116
133
|
kubeNamespace: kubeAnswers.kubeNamespace,
|
|
117
134
|
dockerImageName: kubeAnswers.dockerImageName,
|
|
135
|
+
dockerRegistryUrl: kubeAnswers.dockerRegistryUrl,
|
|
136
|
+
dockerRegistryUsername: kubeAnswers.dockerRegistryUsername,
|
|
137
|
+
dockerRegistryToken: kubeAnswers.dockerRegistryToken,
|
|
118
138
|
healthUrl: kubeAnswers.healthUrl,
|
|
119
139
|
};
|
|
120
140
|
}
|
|
@@ -366,6 +386,7 @@ export function buildServerEnvEntry(
|
|
|
366
386
|
envEntry.kubeContext = deployAnswers.kubeContext;
|
|
367
387
|
envEntry.kubeNamespace = deployAnswers.kubeNamespace || projectName;
|
|
368
388
|
envEntry.dockerImageName = deployAnswers.dockerImageName || projectName;
|
|
389
|
+
envEntry.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
|
|
369
390
|
return envEntry;
|
|
370
391
|
}
|
|
371
392
|
|
|
@@ -418,7 +439,7 @@ export function buildServerEnvEntry(
|
|
|
418
439
|
* @returns {Record<string, string>|null}
|
|
419
440
|
*/
|
|
420
441
|
export function getDockerEnvSecrets(deployAnswers) {
|
|
421
|
-
if (deployAnswers.deployType
|
|
442
|
+
if (!['docker', 'kubernetes'].includes(deployAnswers.deployType)) return null;
|
|
422
443
|
|
|
423
444
|
/** @type {Record<string, string>} */
|
|
424
445
|
const vars = {};
|
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import path from 'path';
|
|
4
2
|
import { createLogger } from '../../logger/index.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
describeInterpretedBackendGap,
|
|
8
|
-
generateDotnetRuntimeDockerfile,
|
|
9
|
-
generateFrontendRuntimeDockerfile,
|
|
10
|
-
generateGoRuntimeDockerfile,
|
|
11
|
-
generateSpringRuntimeDockerfile,
|
|
12
|
-
isFrontendStaticFramework,
|
|
13
|
-
isInterpretedBackendFramework,
|
|
14
|
-
resolveDockerImageRef,
|
|
15
|
-
} from '../../utils/docker-image.js';
|
|
3
|
+
import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
|
|
16
4
|
|
|
17
5
|
/**
|
|
18
6
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -21,277 +9,8 @@ import {
|
|
|
21
9
|
*/
|
|
22
10
|
export function createDockerProvider(config, envName, env = process.env) {
|
|
23
11
|
const log = createLogger('docker');
|
|
24
|
-
|
|
25
|
-
const { fullImage,
|
|
26
|
-
resolveDockerImageRef(config, env);
|
|
27
|
-
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
28
|
-
const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
|
|
29
|
-
const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
|
|
30
|
-
const dockerHost = env.DOCKER_HOST || '';
|
|
31
|
-
|
|
32
|
-
function getDockerEnv() {
|
|
33
|
-
/** @type {Record<string, string>} */
|
|
34
|
-
const dockerEnv = { ...process.env };
|
|
35
|
-
if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
|
|
36
|
-
if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
|
|
37
|
-
if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
|
|
38
|
-
return dockerEnv;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function hasRegistryCredentials() {
|
|
42
|
-
return Boolean(registryUser && registryToken);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function dockerLogin() {
|
|
46
|
-
if (!hasRegistryCredentials()) return;
|
|
47
|
-
const registry = registryUrl || 'https://index.docker.io/v1/';
|
|
48
|
-
log.info('Logging in to container registry...');
|
|
49
|
-
await execa(
|
|
50
|
-
'docker',
|
|
51
|
-
['login', registry, '-u', registryUser, '--password-stdin'],
|
|
52
|
-
{
|
|
53
|
-
input: registryToken,
|
|
54
|
-
stdio: ['pipe', 'inherit', 'inherit'],
|
|
55
|
-
env: getDockerEnv(),
|
|
56
|
-
}
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Push only when registry credentials are configured. Avoids a noisy failed
|
|
62
|
-
* push to Docker Hub for local-only image names.
|
|
63
|
-
*/
|
|
64
|
-
async function maybePushImage() {
|
|
65
|
-
if (!hasRegistryCredentials()) {
|
|
66
|
-
log.info(
|
|
67
|
-
'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
|
|
68
|
-
);
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
log.info(`Pushing ${fullImage} to registry...`);
|
|
73
|
-
await execa('docker', ['push', fullImage], {
|
|
74
|
-
stdio: 'inherit',
|
|
75
|
-
env: getDockerEnv(),
|
|
76
|
-
});
|
|
77
|
-
log.success(`Pushed ${fullImage}`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* @param {string} ref
|
|
82
|
-
*/
|
|
83
|
-
async function imageExists(ref) {
|
|
84
|
-
try {
|
|
85
|
-
await execa('docker', ['image', 'inspect', ref], {
|
|
86
|
-
stdio: 'pipe',
|
|
87
|
-
env: getDockerEnv(),
|
|
88
|
-
});
|
|
89
|
-
return true;
|
|
90
|
-
} catch {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Prefer the image already built during the pipeline `docker` stage.
|
|
97
|
-
* Retag when the pipeline used `:latest` and deploy needs a version tag.
|
|
98
|
-
*/
|
|
99
|
-
async function ensureImageFromPipeline() {
|
|
100
|
-
if (await imageExists(fullImage)) {
|
|
101
|
-
log.info(`Reusing existing image ${fullImage}`);
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
|
|
106
|
-
(ref) => ref !== fullImage
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
for (const candidate of candidates) {
|
|
110
|
-
if (!(await imageExists(candidate))) continue;
|
|
111
|
-
log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
|
|
112
|
-
await execa('docker', ['tag', candidate, fullImage], {
|
|
113
|
-
stdio: 'inherit',
|
|
114
|
-
env: getDockerEnv(),
|
|
115
|
-
});
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Backend artifacts omit lockfiles/node_modules. Prefer a pre-built binary
|
|
124
|
-
* runtime image when present; otherwise fail with an actionable error.
|
|
125
|
-
* @param {string} buildContext
|
|
126
|
-
* @param {Record<string, unknown>} metadata
|
|
127
|
-
* @param {string} framework
|
|
128
|
-
* @param {number} port
|
|
129
|
-
*/
|
|
130
|
-
async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
|
|
131
|
-
if (framework === 'spring') {
|
|
132
|
-
const targetDir = path.join(buildContext, 'target');
|
|
133
|
-
if (await fs.pathExists(targetDir)) {
|
|
134
|
-
const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
|
|
135
|
-
if (jars.length > 0) {
|
|
136
|
-
const jarRel = `target/${jars[0]}`;
|
|
137
|
-
log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
|
|
138
|
-
await fs.writeFile(
|
|
139
|
-
path.join(buildContext, 'Dockerfile'),
|
|
140
|
-
generateSpringRuntimeDockerfile(jarRel, port)
|
|
141
|
-
);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (framework === 'go') {
|
|
148
|
-
const binDir = path.join(buildContext, 'bin');
|
|
149
|
-
if (await fs.pathExists(binDir)) {
|
|
150
|
-
const bins = await fs.readdir(binDir);
|
|
151
|
-
if (bins.length > 0) {
|
|
152
|
-
const binRel = `bin/${bins[0]}`;
|
|
153
|
-
log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
|
|
154
|
-
await fs.writeFile(
|
|
155
|
-
path.join(buildContext, 'Dockerfile'),
|
|
156
|
-
generateGoRuntimeDockerfile(binRel, port)
|
|
157
|
-
);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (framework === 'dotnet') {
|
|
164
|
-
const publishDir =
|
|
165
|
-
/** @type {string} */ (metadata.buildOutput) ||
|
|
166
|
-
config.buildOutput ||
|
|
167
|
-
'publish';
|
|
168
|
-
const publishPath = path.join(buildContext, publishDir);
|
|
169
|
-
if (await fs.pathExists(publishPath)) {
|
|
170
|
-
log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
|
|
171
|
-
await fs.writeFile(
|
|
172
|
-
path.join(buildContext, 'Dockerfile'),
|
|
173
|
-
generateDotnetRuntimeDockerfile(publishDir, port)
|
|
174
|
-
);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const dockerfilePath = path.join(buildContext, 'Dockerfile');
|
|
180
|
-
if (!(await fs.pathExists(dockerfilePath))) {
|
|
181
|
-
throw new Error(
|
|
182
|
-
'No Dockerfile found in artifact and no pipeline image to reuse. ' +
|
|
183
|
-
'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Interpreted backends (Node/Python/PHP/Ruby): never rebuild from artifact.
|
|
188
|
-
// Artifacts ship source + manifest files, not installed dependency trees.
|
|
189
|
-
if (isInterpretedBackendFramework(framework)) {
|
|
190
|
-
const gap = describeInterpretedBackendGap(framework);
|
|
191
|
-
throw new Error(
|
|
192
|
-
`Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
|
|
193
|
-
`Backend artifacts include source/manifests but not ${gap.missing}, ` +
|
|
194
|
-
`so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
|
|
195
|
-
'What to do instead:\n' +
|
|
196
|
-
' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
|
|
197
|
-
' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
|
|
198
|
-
' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
|
|
199
|
-
`Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Remaining backends (unknown frameworks): try the packaged Dockerfile, but warn.
|
|
204
|
-
log.warn(
|
|
205
|
-
'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
|
|
206
|
-
'is built once from full project source, then reused on deploy.'
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Standalone deploy fallback: extract the packaged artifact and build a
|
|
212
|
-
* runtime image from pre-built output (frontend) or compiled backend artifacts.
|
|
213
|
-
* Never builds from artifactDir root — that only has zip/metadata + a source Dockerfile.
|
|
214
|
-
* @param {string} artifactDir
|
|
215
|
-
*/
|
|
216
|
-
async function buildFromArtifactContents(artifactDir) {
|
|
217
|
-
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
218
|
-
if (!(await fs.pathExists(zipPath))) {
|
|
219
|
-
throw new Error(
|
|
220
|
-
`No local image found for ${fullImage} and no artifact.zip to build from. ` +
|
|
221
|
-
'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const buildContext = path.join(artifactDir, '_docker_build');
|
|
226
|
-
await fs.remove(buildContext);
|
|
227
|
-
await extractArtifact(artifactDir, buildContext);
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
const metadataPath = path.join(buildContext, 'metadata.json');
|
|
231
|
-
const metadata = (await fs.pathExists(metadataPath))
|
|
232
|
-
? await fs.readJson(metadataPath)
|
|
233
|
-
: {};
|
|
234
|
-
|
|
235
|
-
const projectType = metadata.projectType || config.projectType || 'frontend';
|
|
236
|
-
const framework =
|
|
237
|
-
metadata.framework ||
|
|
238
|
-
config.framework ||
|
|
239
|
-
config.frontend?.framework ||
|
|
240
|
-
'';
|
|
241
|
-
const buildOutput =
|
|
242
|
-
metadata.buildOutput ||
|
|
243
|
-
config.buildOutput ||
|
|
244
|
-
config.frontend?.buildOutput ||
|
|
245
|
-
'dist';
|
|
246
|
-
const port =
|
|
247
|
-
Number(metadata.port) ||
|
|
248
|
-
config.port ||
|
|
249
|
-
config.backend?.port ||
|
|
250
|
-
3000;
|
|
251
|
-
|
|
252
|
-
const composePath = path.join(buildContext, 'docker-compose.yml');
|
|
253
|
-
if (await fs.pathExists(composePath)) {
|
|
254
|
-
log.info('Building via docker compose from extracted artifact...');
|
|
255
|
-
await execa('docker', ['compose', 'up', '-d', '--build'], {
|
|
256
|
-
cwd: buildContext,
|
|
257
|
-
stdio: 'inherit',
|
|
258
|
-
env: getDockerEnv(),
|
|
259
|
-
});
|
|
260
|
-
return { ranCompose: true };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const isStaticFrontend =
|
|
264
|
-
projectType === 'frontend' || isFrontendStaticFramework(framework);
|
|
265
|
-
|
|
266
|
-
if (isStaticFrontend) {
|
|
267
|
-
const outputDir = path.join(buildContext, buildOutput);
|
|
268
|
-
if (!(await fs.pathExists(outputDir))) {
|
|
269
|
-
throw new Error(
|
|
270
|
-
`Frontend artifact is missing build output "${buildOutput}". ` +
|
|
271
|
-
'Cannot build a runtime image from this artifact.'
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
log.info(
|
|
275
|
-
`Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
|
|
276
|
-
);
|
|
277
|
-
await fs.writeFile(
|
|
278
|
-
path.join(buildContext, 'Dockerfile'),
|
|
279
|
-
generateFrontendRuntimeDockerfile(buildOutput)
|
|
280
|
-
);
|
|
281
|
-
} else {
|
|
282
|
-
await prepareBackendBuildContext(buildContext, metadata, framework, port);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
await execa('docker', ['build', '-t', fullImage, '.'], {
|
|
286
|
-
cwd: buildContext,
|
|
287
|
-
stdio: 'inherit',
|
|
288
|
-
env: getDockerEnv(),
|
|
289
|
-
});
|
|
290
|
-
return { ranCompose: false };
|
|
291
|
-
} finally {
|
|
292
|
-
await fs.remove(buildContext).catch(() => {});
|
|
293
|
-
}
|
|
294
|
-
}
|
|
12
|
+
const imageOps = createDockerImageDeployContext(config, env, log);
|
|
13
|
+
const { fullImage, getDockerEnv, ensureImageReadyForDeploy } = imageOps;
|
|
295
14
|
|
|
296
15
|
/**
|
|
297
16
|
* @param {string} artifactDir
|
|
@@ -300,31 +19,12 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
300
19
|
log.info(`Deploying via Docker (image: ${fullImage})...`);
|
|
301
20
|
const dockerEnv = getDockerEnv();
|
|
302
21
|
|
|
303
|
-
await
|
|
304
|
-
|
|
305
|
-
const reused = await ensureImageFromPipeline();
|
|
306
|
-
let ranCompose = false;
|
|
307
|
-
|
|
308
|
-
if (!reused) {
|
|
309
|
-
const result = await buildFromArtifactContents(artifactDir);
|
|
310
|
-
ranCompose = Boolean(result?.ranCompose);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (ranCompose) {
|
|
22
|
+
const result = await ensureImageReadyForDeploy(artifactDir);
|
|
23
|
+
if (result.ranCompose) {
|
|
314
24
|
log.success('Docker deployment complete');
|
|
315
25
|
return;
|
|
316
26
|
}
|
|
317
27
|
|
|
318
|
-
await maybePushImage();
|
|
319
|
-
|
|
320
|
-
// Keep :latest in sync when deploy uses a version tag
|
|
321
|
-
if (imageTag !== 'latest' && fullImage !== latestImage) {
|
|
322
|
-
await execa('docker', ['tag', fullImage, latestImage], {
|
|
323
|
-
stdio: 'pipe',
|
|
324
|
-
env: dockerEnv,
|
|
325
|
-
}).catch(() => {});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
28
|
await execa(
|
|
329
29
|
'docker',
|
|
330
30
|
['rm', '-f', config.project],
|
|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { createLogger } from '../../logger/index.js';
|
|
6
6
|
import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
|
|
7
|
+
import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -12,6 +13,7 @@ import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
|
|
|
12
13
|
*/
|
|
13
14
|
export function createKubernetesProvider(config, envName, env = process.env) {
|
|
14
15
|
const log = createLogger('kubernetes');
|
|
16
|
+
const imageOps = createDockerImageDeployContext(config, env, log);
|
|
15
17
|
|
|
16
18
|
const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
17
19
|
const context = env.KUBE_CONTEXT || '';
|
|
@@ -51,6 +53,14 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
51
53
|
);
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
|
|
57
|
+
const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
|
|
58
|
+
if (imageResult.ranCompose) {
|
|
59
|
+
log.warn(
|
|
60
|
+
'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
|
|
55
65
|
? path.join(manifestDir, 'k8s')
|
|
56
66
|
: manifestDir;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { extractArtifact } from '../artifact/engine.js';
|
|
5
|
+
import {
|
|
6
|
+
describeInterpretedBackendGap,
|
|
7
|
+
generateDotnetRuntimeDockerfile,
|
|
8
|
+
generateFrontendRuntimeDockerfile,
|
|
9
|
+
generateGoRuntimeDockerfile,
|
|
10
|
+
generateSpringRuntimeDockerfile,
|
|
11
|
+
isFrontendStaticFramework,
|
|
12
|
+
isInterpretedBackendFramework,
|
|
13
|
+
resolveDockerImageRef,
|
|
14
|
+
} from './docker-image.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shared Docker image build, reuse, push, and pullability logic used by
|
|
18
|
+
* docker and kubernetes deploy providers.
|
|
19
|
+
*
|
|
20
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
21
|
+
* @param {Record<string, string>} [env]
|
|
22
|
+
* @param {{ info: Function, warn: Function, success: Function }} log
|
|
23
|
+
*/
|
|
24
|
+
export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
25
|
+
const { fullImage, latestImage, legacyLatestImage, imageTag } =
|
|
26
|
+
resolveDockerImageRef(config, env);
|
|
27
|
+
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
28
|
+
const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
|
|
29
|
+
const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
|
|
30
|
+
const dockerHost = env.DOCKER_HOST || '';
|
|
31
|
+
|
|
32
|
+
function getDockerEnv() {
|
|
33
|
+
/** @type {Record<string, string>} */
|
|
34
|
+
const dockerEnv = { ...process.env };
|
|
35
|
+
if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
|
|
36
|
+
if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
|
|
37
|
+
if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
|
|
38
|
+
return dockerEnv;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasRegistryCredentials() {
|
|
42
|
+
return Boolean(registryUser && registryToken);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function dockerLogin() {
|
|
46
|
+
if (!hasRegistryCredentials()) return;
|
|
47
|
+
const registry = registryUrl || 'https://index.docker.io/v1/';
|
|
48
|
+
log.info('Logging in to container registry...');
|
|
49
|
+
await execa(
|
|
50
|
+
'docker',
|
|
51
|
+
['login', registry, '-u', registryUser, '--password-stdin'],
|
|
52
|
+
{
|
|
53
|
+
input: registryToken,
|
|
54
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
55
|
+
env: getDockerEnv(),
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Push only when registry credentials are configured. Avoids a noisy failed
|
|
62
|
+
* push to Docker Hub for local-only image names.
|
|
63
|
+
*/
|
|
64
|
+
async function maybePushImage() {
|
|
65
|
+
if (!hasRegistryCredentials()) {
|
|
66
|
+
log.info(
|
|
67
|
+
'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log.info(`Pushing ${fullImage} to registry...`);
|
|
73
|
+
await execa('docker', ['push', fullImage], {
|
|
74
|
+
stdio: 'inherit',
|
|
75
|
+
env: getDockerEnv(),
|
|
76
|
+
});
|
|
77
|
+
log.success(`Pushed ${fullImage}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} ref
|
|
82
|
+
*/
|
|
83
|
+
async function imageExistsLocally(ref) {
|
|
84
|
+
try {
|
|
85
|
+
await execa('docker', ['image', 'inspect', ref], {
|
|
86
|
+
stdio: 'pipe',
|
|
87
|
+
env: getDockerEnv(),
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Prefer the image already built during the pipeline `docker` stage.
|
|
97
|
+
* Retag when the pipeline used `:latest` and deploy needs a version tag.
|
|
98
|
+
*/
|
|
99
|
+
async function ensureImageFromPipeline() {
|
|
100
|
+
if (await imageExistsLocally(fullImage)) {
|
|
101
|
+
log.info(`Reusing existing image ${fullImage}`);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
|
|
106
|
+
(ref) => ref !== fullImage
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
if (!(await imageExistsLocally(candidate))) continue;
|
|
111
|
+
log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
|
|
112
|
+
await execa('docker', ['tag', candidate, fullImage], {
|
|
113
|
+
stdio: 'inherit',
|
|
114
|
+
env: getDockerEnv(),
|
|
115
|
+
});
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} buildContext
|
|
124
|
+
* @param {Record<string, unknown>} metadata
|
|
125
|
+
* @param {string} framework
|
|
126
|
+
* @param {number} port
|
|
127
|
+
*/
|
|
128
|
+
async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
|
|
129
|
+
if (framework === 'spring') {
|
|
130
|
+
const targetDir = path.join(buildContext, 'target');
|
|
131
|
+
if (await fs.pathExists(targetDir)) {
|
|
132
|
+
const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
|
|
133
|
+
if (jars.length > 0) {
|
|
134
|
+
const jarRel = `target/${jars[0]}`;
|
|
135
|
+
log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
|
|
136
|
+
await fs.writeFile(
|
|
137
|
+
path.join(buildContext, 'Dockerfile'),
|
|
138
|
+
generateSpringRuntimeDockerfile(jarRel, port)
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (framework === 'go') {
|
|
146
|
+
const binDir = path.join(buildContext, 'bin');
|
|
147
|
+
if (await fs.pathExists(binDir)) {
|
|
148
|
+
const bins = await fs.readdir(binDir);
|
|
149
|
+
if (bins.length > 0) {
|
|
150
|
+
const binRel = `bin/${bins[0]}`;
|
|
151
|
+
log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
|
|
152
|
+
await fs.writeFile(
|
|
153
|
+
path.join(buildContext, 'Dockerfile'),
|
|
154
|
+
generateGoRuntimeDockerfile(binRel, port)
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (framework === 'dotnet') {
|
|
162
|
+
const publishDir =
|
|
163
|
+
/** @type {string} */ (metadata.buildOutput) ||
|
|
164
|
+
config.buildOutput ||
|
|
165
|
+
'publish';
|
|
166
|
+
const publishPath = path.join(buildContext, publishDir);
|
|
167
|
+
if (await fs.pathExists(publishPath)) {
|
|
168
|
+
log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
|
|
169
|
+
await fs.writeFile(
|
|
170
|
+
path.join(buildContext, 'Dockerfile'),
|
|
171
|
+
generateDotnetRuntimeDockerfile(publishDir, port)
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const dockerfilePath = path.join(buildContext, 'Dockerfile');
|
|
178
|
+
if (!(await fs.pathExists(dockerfilePath))) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'No Dockerfile found in artifact and no pipeline image to reuse. ' +
|
|
181
|
+
'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isInterpretedBackendFramework(framework)) {
|
|
186
|
+
const gap = describeInterpretedBackendGap(framework);
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
|
|
189
|
+
`Backend artifacts include source/manifests but not ${gap.missing}, ` +
|
|
190
|
+
`so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
|
|
191
|
+
'What to do instead:\n' +
|
|
192
|
+
' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
|
|
193
|
+
' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
|
|
194
|
+
' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
|
|
195
|
+
`Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
log.warn(
|
|
200
|
+
'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
|
|
201
|
+
'is built once from full project source, then reused on deploy.'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {string} artifactDir
|
|
207
|
+
*/
|
|
208
|
+
async function buildFromArtifactContents(artifactDir) {
|
|
209
|
+
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
210
|
+
if (!(await fs.pathExists(zipPath))) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`No local image found for ${fullImage} and no artifact.zip to build from. ` +
|
|
213
|
+
'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const buildContext = path.join(artifactDir, '_docker_build');
|
|
218
|
+
await fs.remove(buildContext);
|
|
219
|
+
await extractArtifact(artifactDir, buildContext);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const metadataPath = path.join(buildContext, 'metadata.json');
|
|
223
|
+
const metadata = (await fs.pathExists(metadataPath))
|
|
224
|
+
? await fs.readJson(metadataPath)
|
|
225
|
+
: {};
|
|
226
|
+
|
|
227
|
+
const projectType = metadata.projectType || config.projectType || 'frontend';
|
|
228
|
+
const framework =
|
|
229
|
+
metadata.framework ||
|
|
230
|
+
config.framework ||
|
|
231
|
+
config.frontend?.framework ||
|
|
232
|
+
'';
|
|
233
|
+
const buildOutput =
|
|
234
|
+
metadata.buildOutput ||
|
|
235
|
+
config.buildOutput ||
|
|
236
|
+
config.frontend?.buildOutput ||
|
|
237
|
+
'dist';
|
|
238
|
+
const port =
|
|
239
|
+
Number(metadata.port) ||
|
|
240
|
+
config.port ||
|
|
241
|
+
config.backend?.port ||
|
|
242
|
+
3000;
|
|
243
|
+
|
|
244
|
+
const composePath = path.join(buildContext, 'docker-compose.yml');
|
|
245
|
+
if (await fs.pathExists(composePath)) {
|
|
246
|
+
log.info('Building via docker compose from extracted artifact...');
|
|
247
|
+
await execa('docker', ['compose', 'up', '-d', '--build'], {
|
|
248
|
+
cwd: buildContext,
|
|
249
|
+
stdio: 'inherit',
|
|
250
|
+
env: getDockerEnv(),
|
|
251
|
+
});
|
|
252
|
+
return { ranCompose: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const isStaticFrontend =
|
|
256
|
+
projectType === 'frontend' || isFrontendStaticFramework(framework);
|
|
257
|
+
|
|
258
|
+
if (isStaticFrontend) {
|
|
259
|
+
const outputDir = path.join(buildContext, buildOutput);
|
|
260
|
+
if (!(await fs.pathExists(outputDir))) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Frontend artifact is missing build output "${buildOutput}". ` +
|
|
263
|
+
'Cannot build a runtime image from this artifact.'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
log.info(
|
|
267
|
+
`Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
|
|
268
|
+
);
|
|
269
|
+
await fs.writeFile(
|
|
270
|
+
path.join(buildContext, 'Dockerfile'),
|
|
271
|
+
generateFrontendRuntimeDockerfile(buildOutput)
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
await prepareBackendBuildContext(buildContext, metadata, framework, port);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await execa('docker', ['build', '-t', fullImage, '.'], {
|
|
278
|
+
cwd: buildContext,
|
|
279
|
+
stdio: 'inherit',
|
|
280
|
+
env: getDockerEnv(),
|
|
281
|
+
});
|
|
282
|
+
return { ranCompose: false };
|
|
283
|
+
} finally {
|
|
284
|
+
await fs.remove(buildContext).catch(() => {});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Ensure a deployable image exists locally, then push when credentials are set.
|
|
290
|
+
* @param {string} artifactDir
|
|
291
|
+
* @param {{ skipPush?: boolean }} [options]
|
|
292
|
+
*/
|
|
293
|
+
async function ensureImageReadyForDeploy(artifactDir, options = {}) {
|
|
294
|
+
await dockerLogin();
|
|
295
|
+
|
|
296
|
+
const reused = await ensureImageFromPipeline();
|
|
297
|
+
let ranCompose = false;
|
|
298
|
+
|
|
299
|
+
if (!reused) {
|
|
300
|
+
const result = await buildFromArtifactContents(artifactDir);
|
|
301
|
+
ranCompose = Boolean(result?.ranCompose);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (ranCompose) {
|
|
305
|
+
return { ranCompose: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!options.skipPush) {
|
|
309
|
+
await maybePushImage();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const dockerEnv = getDockerEnv();
|
|
313
|
+
if (imageTag !== 'latest' && fullImage !== latestImage) {
|
|
314
|
+
await execa('docker', ['tag', fullImage, latestImage], {
|
|
315
|
+
stdio: 'pipe',
|
|
316
|
+
env: dockerEnv,
|
|
317
|
+
}).catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { ranCompose: false };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
fullImage,
|
|
325
|
+
latestImage,
|
|
326
|
+
imageTag,
|
|
327
|
+
getDockerEnv,
|
|
328
|
+
hasRegistryCredentials,
|
|
329
|
+
dockerLogin,
|
|
330
|
+
maybePushImage,
|
|
331
|
+
ensureImageFromPipeline,
|
|
332
|
+
buildFromArtifactContents,
|
|
333
|
+
ensureImageReadyForDeploy,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Verify that an image:tag exists in a registry and is accessible with current credentials.
|
|
339
|
+
*
|
|
340
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
341
|
+
* @param {Record<string, string>} [env]
|
|
342
|
+
* @returns {Promise<{ ok: boolean, message: string }>}
|
|
343
|
+
*/
|
|
344
|
+
export async function checkImagePullability(config, env = process.env) {
|
|
345
|
+
const { fullImage } = resolveDockerImageRef(config, env);
|
|
346
|
+
const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
|
|
347
|
+
const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
|
|
348
|
+
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
349
|
+
const dockerHost = env.DOCKER_HOST || '';
|
|
350
|
+
|
|
351
|
+
/** @type {Record<string, string>} */
|
|
352
|
+
const dockerEnv = { ...process.env };
|
|
353
|
+
if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
|
|
354
|
+
if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
|
|
355
|
+
if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
|
|
356
|
+
|
|
357
|
+
if (registryUser && registryToken) {
|
|
358
|
+
const registry = registryUrl || 'https://index.docker.io/v1/';
|
|
359
|
+
try {
|
|
360
|
+
await execa(
|
|
361
|
+
'docker',
|
|
362
|
+
['login', registry, '-u', registryUser, '--password-stdin'],
|
|
363
|
+
{
|
|
364
|
+
input: registryToken,
|
|
365
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
366
|
+
env: dockerEnv,
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
message:
|
|
374
|
+
`Image ${fullImage} is not pullable — registry login failed (${msg}). ` +
|
|
375
|
+
'Check DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
await execa('docker', ['manifest', 'inspect', fullImage], {
|
|
382
|
+
stdio: 'pipe',
|
|
383
|
+
env: dockerEnv,
|
|
384
|
+
});
|
|
385
|
+
return { ok: true, message: `Image ${fullImage} is pullable` };
|
|
386
|
+
} catch (err) {
|
|
387
|
+
const stderr =
|
|
388
|
+
err instanceof Error && 'stderr' in err
|
|
389
|
+
? String(/** @type {{ stderr?: string }} */ (err).stderr || '')
|
|
390
|
+
: '';
|
|
391
|
+
const combined = `${err instanceof Error ? err.message : String(err)} ${stderr}`.toLowerCase();
|
|
392
|
+
|
|
393
|
+
let hint = '';
|
|
394
|
+
if (combined.includes('unauthorized') || combined.includes('authentication required')) {
|
|
395
|
+
hint =
|
|
396
|
+
'The image may exist but is private and inaccessible with your current credentials. ' +
|
|
397
|
+
'Set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN, or configure KUBE_IMAGE_PULL_SECRET for the cluster.';
|
|
398
|
+
} else if (combined.includes('not found') || combined.includes('manifest unknown')) {
|
|
399
|
+
hint =
|
|
400
|
+
'The image does not exist in the registry yet.';
|
|
401
|
+
} else if (combined.includes('denied')) {
|
|
402
|
+
hint = 'Access denied — check registry credentials and repository permissions.';
|
|
403
|
+
} else {
|
|
404
|
+
hint = 'Verify the image name/tag and registry connectivity.';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const hasCreds = Boolean(registryUser && registryToken);
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
message:
|
|
412
|
+
`Image ${fullImage} is not pullable. ${hint}\n` +
|
|
413
|
+
(hasCreds
|
|
414
|
+
? ' DeployHub will build and push this image automatically during your next `deployhub build`.\n'
|
|
415
|
+
: ' If you have DOCKER_REGISTRY_USERNAME/DOCKER_REGISTRY_TOKEN configured, DeployHub will build and push this image automatically during your next `deployhub build`.\n' +
|
|
416
|
+
' If not, either configure those variables, or push the image manually before deploying:\n') +
|
|
417
|
+
` docker push ${fullImage}`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -3,7 +3,8 @@ import path from 'path';
|
|
|
3
3
|
import { getWorkflowHeaderComment } from './author.js';
|
|
4
4
|
import {
|
|
5
5
|
generateDeploymentEnvSection,
|
|
6
|
-
|
|
6
|
+
getDeploymentWorkflowSecretKeys,
|
|
7
|
+
getDeploymentSecretChecklistItems,
|
|
7
8
|
DEPLOYMENT_ENV_KEYS,
|
|
8
9
|
} from '../deployment/deployment-env.js';
|
|
9
10
|
|
|
@@ -287,7 +288,7 @@ export function generateWorkflowYaml(
|
|
|
287
288
|
const env = environments[envName];
|
|
288
289
|
if (!env) continue;
|
|
289
290
|
|
|
290
|
-
const keys =
|
|
291
|
+
const keys = getDeploymentWorkflowSecretKeys(env.type, config);
|
|
291
292
|
for (const key of keys) {
|
|
292
293
|
envVars.add(`${key}: \${{ secrets.${key} }}`);
|
|
293
294
|
}
|
|
@@ -438,10 +439,12 @@ export async function guessCliGithubRepo(cwd = process.cwd()) {
|
|
|
438
439
|
}
|
|
439
440
|
|
|
440
441
|
/**
|
|
442
|
+
* Flat list of required secret key names (for callers that only need keys).
|
|
441
443
|
* @param {string[]} storageProviders
|
|
442
444
|
* @param {string[]} deployEnvironments
|
|
443
445
|
* @param {Record<string, { type: string }>} environments
|
|
444
446
|
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
447
|
+
* @param {string|null} [cliSource]
|
|
445
448
|
* @returns {string[]}
|
|
446
449
|
*/
|
|
447
450
|
export function getRequiredSecrets(
|
|
@@ -451,28 +454,69 @@ export function getRequiredSecrets(
|
|
|
451
454
|
config = null,
|
|
452
455
|
cliSource = null
|
|
453
456
|
) {
|
|
454
|
-
|
|
455
|
-
|
|
457
|
+
return getGithubSecretsChecklist(
|
|
458
|
+
storageProviders,
|
|
459
|
+
deployEnvironments,
|
|
460
|
+
environments,
|
|
461
|
+
config,
|
|
462
|
+
cliSource
|
|
463
|
+
)
|
|
464
|
+
.filter((item) => item.required)
|
|
465
|
+
.map((item) => item.key);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Labeled GitHub Secrets checklist (required + optional) for post-init output.
|
|
470
|
+
* @param {string[]} storageProviders
|
|
471
|
+
* @param {string[]} deployEnvironments
|
|
472
|
+
* @param {Record<string, { type: string }>} environments
|
|
473
|
+
* @param {import('../core/config.js').DeployHubConfig} [config]
|
|
474
|
+
* @param {string|null} [cliSource]
|
|
475
|
+
* @returns {import('../deployment/deployment-env.js').SecretChecklistItem[]}
|
|
476
|
+
*/
|
|
477
|
+
export function getGithubSecretsChecklist(
|
|
478
|
+
storageProviders,
|
|
479
|
+
deployEnvironments,
|
|
480
|
+
environments,
|
|
481
|
+
config = null,
|
|
482
|
+
cliSource = null
|
|
483
|
+
) {
|
|
484
|
+
/** @type {Map<string, import('../deployment/deployment-env.js').SecretChecklistItem>} */
|
|
485
|
+
const byKey = new Map();
|
|
456
486
|
|
|
457
487
|
const resolvedCliSource = cliSource || config?.cli?.source;
|
|
458
488
|
if (isGithubCliSource(resolvedCliSource)) {
|
|
459
|
-
|
|
489
|
+
byKey.set(GITHUB_CLI_TOKEN_SECRET, {
|
|
490
|
+
key: GITHUB_CLI_TOKEN_SECRET,
|
|
491
|
+
required: true,
|
|
492
|
+
note: 'required when installing DeployHub CLI from a private GitHub repo',
|
|
493
|
+
});
|
|
460
494
|
}
|
|
461
495
|
|
|
462
496
|
for (const provider of storageProviders) {
|
|
463
497
|
const keys = PROVIDER_ENV_MAP[provider] || [];
|
|
464
|
-
|
|
498
|
+
for (const key of keys) {
|
|
499
|
+
byKey.set(key, { key, required: true });
|
|
500
|
+
}
|
|
465
501
|
}
|
|
466
502
|
|
|
467
503
|
for (const envName of deployEnvironments) {
|
|
468
504
|
const env = environments[envName];
|
|
469
|
-
if (!env) continue;
|
|
505
|
+
if (!env?.type) continue;
|
|
470
506
|
|
|
471
|
-
const
|
|
472
|
-
|
|
507
|
+
for (const item of getDeploymentSecretChecklistItems(env.type, config)) {
|
|
508
|
+
const existing = byKey.get(item.key);
|
|
509
|
+
if (existing) {
|
|
510
|
+
if (item.required && !existing.required) {
|
|
511
|
+
byKey.set(item.key, item);
|
|
512
|
+
}
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
byKey.set(item.key, item);
|
|
516
|
+
}
|
|
473
517
|
}
|
|
474
518
|
|
|
475
|
-
return Array.from(
|
|
519
|
+
return Array.from(byKey.values());
|
|
476
520
|
}
|
|
477
521
|
|
|
478
522
|
/**
|