@akash-chowdhury-24/deployhub 2.0.7 → 2.0.9
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 +63 -0
- package/src/deployment/deployment-env.js +38 -9
- package/src/deployment/init-prompts.js +22 -1
- package/src/deployment/providers/docker.js +5 -305
- package/src/deployment/providers/kubernetes.js +29 -1
- package/src/utils/docker-image-deploy.js +420 -0
- package/src/utils/interactive.js +27 -0
- package/src/utils/kubernetes-namespace.js +130 -0
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { execa } from 'execa';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
5
6
|
import axios from 'axios';
|
|
6
7
|
import { loadConfig, loadEnv } from '../core/config.js';
|
|
7
8
|
import { testProvider } from '../storage/index.js';
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
formatDeployPathWriteFailure,
|
|
20
21
|
} from '../utils/shell-quote.js';
|
|
21
22
|
import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
|
|
23
|
+
import { checkImagePullability } from '../utils/docker-image-deploy.js';
|
|
24
|
+
import { namespaceExists } from '../utils/kubernetes-namespace.js';
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -410,6 +413,66 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
410
413
|
}
|
|
411
414
|
})
|
|
412
415
|
);
|
|
416
|
+
|
|
417
|
+
checks.push(
|
|
418
|
+
await runCheck('Kubernetes namespace', async () => {
|
|
419
|
+
const ns = process.env.KUBE_NAMESPACE || config.project || 'default';
|
|
420
|
+
const kubeconfig =
|
|
421
|
+
process.env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
422
|
+
const context = process.env.KUBE_CONTEXT || '';
|
|
423
|
+
const expanded = kubeconfig.replace(/^~/, os.homedir());
|
|
424
|
+
const kubectlEnv = { ...process.env, KUBECONFIG: path.resolve(expanded) };
|
|
425
|
+
|
|
426
|
+
/** @param {string[]} baseArgs */
|
|
427
|
+
function kubectlClusterArgs(baseArgs) {
|
|
428
|
+
const args = [...baseArgs];
|
|
429
|
+
if (context) args.push('--context', context);
|
|
430
|
+
return args;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const exists = await namespaceExists(ns, {
|
|
434
|
+
kubectlArgs: kubectlClusterArgs,
|
|
435
|
+
getKubectlEnv: () => kubectlEnv,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (exists) {
|
|
439
|
+
return {
|
|
440
|
+
name: 'Kubernetes namespace',
|
|
441
|
+
pass: true,
|
|
442
|
+
message: `Namespace '${ns}' exists`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
name: 'Kubernetes namespace',
|
|
447
|
+
pass: true,
|
|
448
|
+
message: `Namespace '${ns}' does not exist yet — deploy will prompt locally or auto-create in CI (kubectl create namespace ${ns})`,
|
|
449
|
+
};
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
checks.push(
|
|
454
|
+
await runCheck('Container image pullable', async () => {
|
|
455
|
+
const result = await checkImagePullability(config, process.env);
|
|
456
|
+
return {
|
|
457
|
+
name: 'Container image pullable',
|
|
458
|
+
pass: result.ok,
|
|
459
|
+
message: result.message,
|
|
460
|
+
};
|
|
461
|
+
})
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (deployType === 'docker' && process.env.DOCKER_REGISTRY_USERNAME && process.env.DOCKER_REGISTRY_TOKEN) {
|
|
466
|
+
checks.push(
|
|
467
|
+
await runCheck('Container image pullable', async () => {
|
|
468
|
+
const result = await checkImagePullability(config, process.env);
|
|
469
|
+
return {
|
|
470
|
+
name: 'Container image pullable',
|
|
471
|
+
pass: result.ok,
|
|
472
|
+
message: result.message,
|
|
473
|
+
};
|
|
474
|
+
})
|
|
475
|
+
);
|
|
413
476
|
}
|
|
414
477
|
|
|
415
478
|
if (deployType === 'ec2' && process.env.EC2_INSTANCE_ID) {
|
|
@@ -309,19 +309,18 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
309
309
|
optionalReason: 'defaults to your project name or "default" if unset',
|
|
310
310
|
comment: [
|
|
311
311
|
'Kubernetes namespace for your deployment.',
|
|
312
|
-
'
|
|
312
|
+
'If missing, deployhub deploy prompts to create it locally (or auto-creates in CI).',
|
|
313
313
|
],
|
|
314
314
|
example: 'my-app',
|
|
315
315
|
when: 'optional',
|
|
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',
|
|
@@ -683,21 +706,27 @@ export const DEPLOYMENT_GUIDE = {
|
|
|
683
706
|
before: [
|
|
684
707
|
'An existing Kubernetes cluster (DeployHub does not provision clusters).',
|
|
685
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.',
|
|
686
711
|
'Kubernetes manifests (Deployment, Service, etc.) in your repo or artifact.',
|
|
687
712
|
'Cluster access from CI: kubeconfig or cloud-specific auth for GitHub Actions.',
|
|
688
713
|
],
|
|
689
714
|
automates: [
|
|
690
715
|
'Lists available kubectl contexts during init so you pick from a menu.',
|
|
691
716
|
'Auto-detects ~/.kube/config if present.',
|
|
692
|
-
'Generates complete .env.example for kubeconfig, context, and
|
|
717
|
+
'Generates complete .env.example for kubeconfig, context, namespace, and registry settings.',
|
|
693
718
|
'Tests cluster connectivity during init.',
|
|
719
|
+
'Builds and pushes your container image during deployhub build/deploy (pipeline docker stage).',
|
|
694
720
|
],
|
|
695
721
|
after: [
|
|
696
722
|
'Ensure your kubeconfig context points to the correct cluster.',
|
|
697
|
-
'
|
|
698
|
-
'
|
|
699
|
-
'
|
|
700
|
-
'
|
|
723
|
+
'Namespace is created on first deploy if missing (prompt locally; auto-create in CI). Or: kubectl create namespace YOUR_NAMESPACE',
|
|
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.',
|
|
701
730
|
],
|
|
702
731
|
},
|
|
703
732
|
};
|
|
@@ -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,8 @@ 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';
|
|
8
|
+
import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -12,6 +14,7 @@ import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
|
|
|
12
14
|
*/
|
|
13
15
|
export function createKubernetesProvider(config, envName, env = process.env) {
|
|
14
16
|
const log = createLogger('kubernetes');
|
|
17
|
+
const imageOps = createDockerImageDeployContext(config, env, log);
|
|
15
18
|
|
|
16
19
|
const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
17
20
|
const context = env.KUBE_CONTEXT || '';
|
|
@@ -24,12 +27,22 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/**
|
|
30
|
+
* Cluster-scoped kubectl args (context only). Used for Namespace get/create.
|
|
27
31
|
* @param {string[]} baseArgs
|
|
28
32
|
*/
|
|
29
|
-
function
|
|
33
|
+
function kubectlClusterArgs(baseArgs) {
|
|
30
34
|
/** @type {string[]} */
|
|
31
35
|
const args = [...baseArgs];
|
|
32
36
|
if (context) args.push('--context', context);
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string[]} baseArgs
|
|
42
|
+
*/
|
|
43
|
+
function kubectlArgs(baseArgs) {
|
|
44
|
+
/** @type {string[]} */
|
|
45
|
+
const args = kubectlClusterArgs(baseArgs);
|
|
33
46
|
if (namespace) args.push('--namespace', namespace);
|
|
34
47
|
return args;
|
|
35
48
|
}
|
|
@@ -51,6 +64,21 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
51
64
|
);
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
|
|
68
|
+
const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
|
|
69
|
+
if (imageResult.ranCompose) {
|
|
70
|
+
log.warn(
|
|
71
|
+
'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await ensureKubernetesNamespace({
|
|
76
|
+
namespace,
|
|
77
|
+
log,
|
|
78
|
+
kubectlArgs: kubectlClusterArgs,
|
|
79
|
+
getKubectlEnv,
|
|
80
|
+
});
|
|
81
|
+
|
|
54
82
|
const applyTarget = (await fs.pathExists(path.join(manifestDir, 'k8s')))
|
|
55
83
|
? path.join(manifestDir, 'k8s')
|
|
56
84
|
: 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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether the CLI can safely prompt the user.
|
|
3
|
+
* Non-interactive when stdin is not a TTY, or when common CI env vars are set.
|
|
4
|
+
*
|
|
5
|
+
* @param {{ env?: NodeJS.ProcessEnv, stdinIsTTY?: boolean|null }} [options]
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isInteractive(options = {}) {
|
|
9
|
+
const env = options.env || process.env;
|
|
10
|
+
const stdinIsTTY =
|
|
11
|
+
options.stdinIsTTY !== undefined ? options.stdinIsTTY : Boolean(process.stdin.isTTY);
|
|
12
|
+
|
|
13
|
+
if (env.CI === 'true' || env.CI === '1') return false;
|
|
14
|
+
if (env.GITHUB_ACTIONS === 'true' || env.GITHUB_ACTIONS === '1') return false;
|
|
15
|
+
if (!stdinIsTTY) return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ env?: NodeJS.ProcessEnv, stdinIsTTY?: boolean|null }} [options]
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function isNonInteractive(options = {}) {
|
|
24
|
+
return !isInteractive(options);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default { isInteractive, isNonInteractive };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import { isInteractive } from './interactive.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {unknown} err
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function kubectlErrorDetail(err) {
|
|
10
|
+
if (err && typeof err === 'object' && 'stderr' in err) {
|
|
11
|
+
const stderr = String(/** @type {{ stderr?: unknown }} */ (err).stderr || '').trim();
|
|
12
|
+
if (stderr) return stderr;
|
|
13
|
+
}
|
|
14
|
+
if (err instanceof Error && err.message) return err.message;
|
|
15
|
+
return String(err);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether a namespace exists using a cluster-scoped get that exits 0 for
|
|
20
|
+
* both found and missing. Non-zero exits (auth, connectivity, bad kubeconfig)
|
|
21
|
+
* are rethrown — they must not be treated as "not found".
|
|
22
|
+
*
|
|
23
|
+
* @param {string} namespace
|
|
24
|
+
* @param {{
|
|
25
|
+
* kubectlArgs?: (args: string[]) => string[],
|
|
26
|
+
* getKubectlEnv?: () => NodeJS.ProcessEnv,
|
|
27
|
+
* execaFn?: typeof execa,
|
|
28
|
+
* }} [options]
|
|
29
|
+
* @returns {Promise<boolean>}
|
|
30
|
+
*/
|
|
31
|
+
export async function namespaceExists(namespace, options = {}) {
|
|
32
|
+
const kubectlArgs = options.kubectlArgs || ((args) => args);
|
|
33
|
+
const getKubectlEnv = options.getKubectlEnv || (() => process.env);
|
|
34
|
+
const execaFn = options.execaFn || execa;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execaFn(
|
|
38
|
+
'kubectl',
|
|
39
|
+
kubectlArgs(['get', 'namespace', namespace, '--ignore-not-found', '-o', 'name']),
|
|
40
|
+
{
|
|
41
|
+
stdio: 'pipe',
|
|
42
|
+
env: getKubectlEnv(),
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
return Boolean(stdout && String(stdout).trim());
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Failed to check whether namespace '${namespace}' exists: ${kubectlErrorDetail(err)}`,
|
|
49
|
+
{ cause: err instanceof Error ? err : undefined }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} namespace
|
|
56
|
+
* @returns {Promise<boolean>}
|
|
57
|
+
*/
|
|
58
|
+
async function defaultConfirmCreate(namespace) {
|
|
59
|
+
const { create } = await inquirer.prompt([
|
|
60
|
+
{
|
|
61
|
+
type: 'confirm',
|
|
62
|
+
name: 'create',
|
|
63
|
+
message: `Namespace '${namespace}' does not exist. Create it now?`,
|
|
64
|
+
default: true,
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
return Boolean(create);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ensure the target namespace exists before kubectl apply.
|
|
72
|
+
* Interactive: prompt to create. CI / non-TTY: auto-create with a clear log.
|
|
73
|
+
* Connectivity/auth failures from the existence check abort before create/prompt.
|
|
74
|
+
*
|
|
75
|
+
* @param {{
|
|
76
|
+
* namespace: string,
|
|
77
|
+
* log: { info: Function, warn: Function, success: Function, error?: Function },
|
|
78
|
+
* kubectlArgs?: (args: string[]) => string[],
|
|
79
|
+
* getKubectlEnv?: () => NodeJS.ProcessEnv,
|
|
80
|
+
* execaFn?: typeof execa,
|
|
81
|
+
* confirmFn?: (namespace: string) => Promise<boolean>,
|
|
82
|
+
* interactive?: boolean,
|
|
83
|
+
* }} options
|
|
84
|
+
* @returns {Promise<{ existed: boolean, created: boolean }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function ensureKubernetesNamespace(options) {
|
|
87
|
+
const {
|
|
88
|
+
namespace,
|
|
89
|
+
log,
|
|
90
|
+
kubectlArgs = (args) => args,
|
|
91
|
+
getKubectlEnv = () => process.env,
|
|
92
|
+
execaFn = execa,
|
|
93
|
+
confirmFn = defaultConfirmCreate,
|
|
94
|
+
interactive = isInteractive(),
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
if (!namespace) {
|
|
98
|
+
throw new Error('Kubernetes namespace is empty — set KUBE_NAMESPACE or config.project.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Throws on auth/connectivity/kubeconfig errors — only false means genuine NotFound.
|
|
102
|
+
if (await namespaceExists(namespace, { kubectlArgs, getKubectlEnv, execaFn })) {
|
|
103
|
+
return { existed: true, created: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
log.warn(`Namespace '${namespace}' was not found on the cluster.`);
|
|
107
|
+
|
|
108
|
+
let shouldCreate = true;
|
|
109
|
+
if (interactive) {
|
|
110
|
+
shouldCreate = await confirmFn(namespace);
|
|
111
|
+
if (!shouldCreate) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Namespace '${namespace}' does not exist. Create it manually with: kubectl create namespace ${namespace}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
log.info(
|
|
118
|
+
`Non-interactive session detected — creating namespace '${namespace}' automatically.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await execaFn('kubectl', kubectlArgs(['create', 'namespace', namespace]), {
|
|
123
|
+
stdio: 'inherit',
|
|
124
|
+
env: getKubectlEnv(),
|
|
125
|
+
});
|
|
126
|
+
log.success(`Created namespace '${namespace}'`);
|
|
127
|
+
return { existed: false, created: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default { namespaceExists, ensureKubernetesNamespace };
|