@akash-chowdhury-24/deployhub 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/package.json +1 -1
- package/src/adapters/dotnet.adapter.js +12 -5
- package/src/adapters/go.adapter.js +12 -5
- package/src/adapters/java.adapter.js +12 -5
- package/src/adapters/node.adapter.js +10 -3
- package/src/adapters/php.adapter.js +12 -5
- package/src/adapters/python.adapter.js +7 -1
- package/src/adapters/rails.adapter.js +7 -2
- package/src/artifact/engine.js +13 -0
- package/src/commands/init.js +31 -2
- package/src/core/stages.js +18 -1
- package/src/deployment/deployment-env.js +24 -5
- package/src/deployment/providers/docker.js +283 -36
- package/src/deployment/providers/kubernetes.js +6 -4
- package/src/utils/docker-image.js +181 -0
- package/src/utils/dockerfile.js +584 -0
- package/src/utils/github-actions.js +56 -1
- package/src/utils/kubernetes-manifests.js +168 -0
- package/src/utils/scaffold.js +231 -0
|
@@ -2,6 +2,17 @@ import { execa } from 'execa';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createLogger } from '../../logger/index.js';
|
|
5
|
+
import { extractArtifact } from '../../artifact/engine.js';
|
|
6
|
+
import {
|
|
7
|
+
describeInterpretedBackendGap,
|
|
8
|
+
generateDotnetRuntimeDockerfile,
|
|
9
|
+
generateFrontendRuntimeDockerfile,
|
|
10
|
+
generateGoRuntimeDockerfile,
|
|
11
|
+
generateSpringRuntimeDockerfile,
|
|
12
|
+
isFrontendStaticFramework,
|
|
13
|
+
isInterpretedBackendFramework,
|
|
14
|
+
resolveDockerImageRef,
|
|
15
|
+
} from '../../utils/docker-image.js';
|
|
5
16
|
|
|
6
17
|
/**
|
|
7
18
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -11,8 +22,8 @@ import { createLogger } from '../../logger/index.js';
|
|
|
11
22
|
export function createDockerProvider(config, envName, env = process.env) {
|
|
12
23
|
const log = createLogger('docker');
|
|
13
24
|
|
|
14
|
-
const
|
|
15
|
-
|
|
25
|
+
const { fullImage, latestImage, legacyLatestImage, imageTag } =
|
|
26
|
+
resolveDockerImageRef(config, env);
|
|
16
27
|
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
17
28
|
const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
|
|
18
29
|
const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
|
|
@@ -27,12 +38,12 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
27
38
|
return dockerEnv;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
function hasRegistryCredentials() {
|
|
42
|
+
return Boolean(registryUser && registryToken);
|
|
43
|
+
}
|
|
33
44
|
|
|
34
45
|
async function dockerLogin() {
|
|
35
|
-
if (!
|
|
46
|
+
if (!hasRegistryCredentials()) return;
|
|
36
47
|
const registry = registryUrl || 'https://index.docker.io/v1/';
|
|
37
48
|
log.info('Logging in to container registry...');
|
|
38
49
|
await execa(
|
|
@@ -47,48 +58,284 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/**
|
|
50
|
-
*
|
|
61
|
+
* Push only when registry credentials are configured. Avoids a noisy failed
|
|
62
|
+
* push to Docker Hub for local-only image names.
|
|
51
63
|
*/
|
|
52
|
-
async function
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
|
61
94
|
|
|
62
|
-
|
|
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
|
+
}
|
|
63
104
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
await execa('docker', ['
|
|
72
|
-
cwd: artifactDir,
|
|
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], {
|
|
73
113
|
stdio: 'inherit',
|
|
74
|
-
env:
|
|
114
|
+
env: getDockerEnv(),
|
|
75
115
|
});
|
|
76
|
-
|
|
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,
|
|
77
287
|
stdio: 'inherit',
|
|
78
|
-
env:
|
|
79
|
-
}).catch(() => {
|
|
80
|
-
log.warn('docker push skipped (registry may be local or push not configured)');
|
|
288
|
+
env: getDockerEnv(),
|
|
81
289
|
});
|
|
82
|
-
|
|
83
|
-
|
|
290
|
+
return { ranCompose: false };
|
|
291
|
+
} finally {
|
|
292
|
+
await fs.remove(buildContext).catch(() => {});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {string} artifactDir
|
|
298
|
+
*/
|
|
299
|
+
async function deploy(artifactDir) {
|
|
300
|
+
log.info(`Deploying via Docker (image: ${fullImage})...`);
|
|
301
|
+
const dockerEnv = getDockerEnv();
|
|
302
|
+
|
|
303
|
+
await dockerLogin();
|
|
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) {
|
|
314
|
+
log.success('Docker deployment complete');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
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',
|
|
84
324
|
env: dockerEnv,
|
|
85
|
-
});
|
|
86
|
-
} else {
|
|
87
|
-
throw new Error(
|
|
88
|
-
'No Dockerfile or docker-compose.yml found in artifact. Add one to your project or enable Docker in pipeline config.'
|
|
89
|
-
);
|
|
325
|
+
}).catch(() => {});
|
|
90
326
|
}
|
|
91
327
|
|
|
328
|
+
await execa(
|
|
329
|
+
'docker',
|
|
330
|
+
['rm', '-f', config.project],
|
|
331
|
+
{ stdio: 'pipe', env: dockerEnv }
|
|
332
|
+
).catch(() => {});
|
|
333
|
+
|
|
334
|
+
await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
|
|
335
|
+
stdio: 'inherit',
|
|
336
|
+
env: dockerEnv,
|
|
337
|
+
});
|
|
338
|
+
|
|
92
339
|
log.success('Docker deployment complete');
|
|
93
340
|
}
|
|
94
341
|
|
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { createLogger } from '../../logger/index.js';
|
|
6
|
+
import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @param {import('../../core/config.js').DeployHubConfig} config
|
|
@@ -15,6 +16,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
15
16
|
const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
16
17
|
const context = env.KUBE_CONTEXT || '';
|
|
17
18
|
const namespace = env.KUBE_NAMESPACE || config.project || 'default';
|
|
19
|
+
const deploymentName = sanitizeK8sName(config.project || 'app');
|
|
18
20
|
|
|
19
21
|
function getKubectlEnv() {
|
|
20
22
|
const expanded = kubeconfig.replace(/^~/, os.homedir());
|
|
@@ -66,8 +68,8 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
66
68
|
kubectlArgs([
|
|
67
69
|
'set',
|
|
68
70
|
'image',
|
|
69
|
-
`deployment/${
|
|
70
|
-
`${
|
|
71
|
+
`deployment/${deploymentName}`,
|
|
72
|
+
`${deploymentName}=${imageName}:${imageTag}`,
|
|
71
73
|
]),
|
|
72
74
|
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
73
75
|
).catch(() => {
|
|
@@ -80,7 +82,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
80
82
|
|
|
81
83
|
async function rollback(artifactDir) {
|
|
82
84
|
log.info('Rolling back Kubernetes deployment...');
|
|
83
|
-
await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${
|
|
85
|
+
await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
|
|
84
86
|
stdio: 'inherit',
|
|
85
87
|
env: getKubectlEnv(),
|
|
86
88
|
}).catch(async () => {
|
|
@@ -105,7 +107,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
105
107
|
try {
|
|
106
108
|
await execa(
|
|
107
109
|
'kubectl',
|
|
108
|
-
kubectlArgs(['rollout', 'status', `deployment/${
|
|
110
|
+
kubectlArgs(['rollout', 'status', `deployment/${deploymentName}`, '--timeout=30s']),
|
|
109
111
|
{ stdio: 'pipe', env: getKubectlEnv() }
|
|
110
112
|
);
|
|
111
113
|
return true;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Docker image naming for pipeline builds and deploy.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
7
|
+
* @param {Record<string, string|undefined>} [env]
|
|
8
|
+
* @returns {{
|
|
9
|
+
* imageName: string,
|
|
10
|
+
* imageTag: string,
|
|
11
|
+
* fullImage: string,
|
|
12
|
+
* latestImage: string,
|
|
13
|
+
* legacyLatestImage: string,
|
|
14
|
+
* }}
|
|
15
|
+
*/
|
|
16
|
+
export function resolveDockerImageRef(config, env = process.env) {
|
|
17
|
+
const imageName = env.DOCKER_IMAGE_NAME || config.project;
|
|
18
|
+
const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
|
|
19
|
+
const registryUrl = env.DOCKER_REGISTRY_URL || '';
|
|
20
|
+
|
|
21
|
+
const repository =
|
|
22
|
+
registryUrl && !imageName.includes('/')
|
|
23
|
+
? `${registryUrl.replace(/\/$/, '')}/${imageName}`
|
|
24
|
+
: imageName;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
imageName,
|
|
28
|
+
imageTag,
|
|
29
|
+
fullImage: `${repository}:${imageTag}`,
|
|
30
|
+
latestImage: `${repository}:latest`,
|
|
31
|
+
legacyLatestImage: `${config.project}:latest`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runtime-only Dockerfile for frontend artifacts that already contain built static output.
|
|
37
|
+
* Used when deploy must build from an artifact (no source / no package-lock).
|
|
38
|
+
* @param {string} [buildOutput]
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function generateFrontendRuntimeDockerfile(buildOutput = 'dist') {
|
|
42
|
+
const output = buildOutput.replace(/^\/+|\/+$/g, '') || 'dist';
|
|
43
|
+
return `# Generated by DeployHub for artifact deploy (pre-built static output)
|
|
44
|
+
FROM nginx:alpine
|
|
45
|
+
COPY ${output}/ /usr/share/nginx/html/
|
|
46
|
+
EXPOSE 80
|
|
47
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} jarRelativePath e.g. target/app.jar
|
|
53
|
+
* @param {number} [port]
|
|
54
|
+
*/
|
|
55
|
+
export function generateSpringRuntimeDockerfile(jarRelativePath, port = 8080) {
|
|
56
|
+
return `# Generated by DeployHub for artifact deploy (pre-built JAR)
|
|
57
|
+
FROM eclipse-temurin:17-jre-alpine
|
|
58
|
+
WORKDIR /app
|
|
59
|
+
COPY ${jarRelativePath} app.jar
|
|
60
|
+
EXPOSE ${port}
|
|
61
|
+
CMD ["java", "-jar", "app.jar"]
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} binaryRelativePath e.g. bin/app
|
|
67
|
+
* @param {number} [port]
|
|
68
|
+
*/
|
|
69
|
+
export function generateGoRuntimeDockerfile(binaryRelativePath, port = 8080) {
|
|
70
|
+
return `# Generated by DeployHub for artifact deploy (pre-built Go binary)
|
|
71
|
+
FROM alpine:3.19
|
|
72
|
+
WORKDIR /app
|
|
73
|
+
RUN apk add --no-cache ca-certificates
|
|
74
|
+
COPY ${binaryRelativePath} ./app
|
|
75
|
+
RUN chmod +x ./app
|
|
76
|
+
EXPOSE ${port}
|
|
77
|
+
CMD ["./app"]
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} publishDir
|
|
83
|
+
* @param {number} [port]
|
|
84
|
+
*/
|
|
85
|
+
export function generateDotnetRuntimeDockerfile(publishDir = 'publish', port = 5000) {
|
|
86
|
+
const dir = publishDir.replace(/^\/+|\/+$/g, '') || 'publish';
|
|
87
|
+
return `# Generated by DeployHub for artifact deploy (pre-built .NET publish output)
|
|
88
|
+
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
|
89
|
+
WORKDIR /app
|
|
90
|
+
COPY ${dir}/ .
|
|
91
|
+
EXPOSE ${port}
|
|
92
|
+
ENV ASPNETCORE_URLS=http://+:${port}
|
|
93
|
+
CMD ["dotnet", "App.dll"]
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} framework
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function isFrontendStaticFramework(framework) {
|
|
102
|
+
return ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework || '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Node-style backends whose generated Dockerfiles run npm ci and need a lockfile.
|
|
107
|
+
* @param {string} framework
|
|
108
|
+
*/
|
|
109
|
+
export function isNodeBackendFramework(framework) {
|
|
110
|
+
return ['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework || '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Interpreted backends that install deps at image-build time (pip/composer/bundle/npm).
|
|
115
|
+
* Their artifacts lack vendor/venv/node_modules, so standalone artifact rebuild is unsupported.
|
|
116
|
+
* @param {string} framework
|
|
117
|
+
*/
|
|
118
|
+
export function isInterpretedBackendFramework(framework) {
|
|
119
|
+
return [
|
|
120
|
+
...['express', 'nestjs', 'fastify', 'koa', 'nextjs'],
|
|
121
|
+
'fastapi',
|
|
122
|
+
'django',
|
|
123
|
+
'flask',
|
|
124
|
+
'laravel',
|
|
125
|
+
'symfony',
|
|
126
|
+
'rails',
|
|
127
|
+
].includes(framework || '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Human-readable dependency gap for interpreted backend artifact rebuilds.
|
|
132
|
+
* @param {string} framework
|
|
133
|
+
* @returns {{ ecosystem: string, missing: string, installCmd: string }}
|
|
134
|
+
*/
|
|
135
|
+
export function describeInterpretedBackendGap(framework) {
|
|
136
|
+
if (isNodeBackendFramework(framework)) {
|
|
137
|
+
return {
|
|
138
|
+
ecosystem: 'Node',
|
|
139
|
+
missing: 'package-lock.json / node_modules',
|
|
140
|
+
installCmd: 'npm ci',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (['fastapi', 'django', 'flask'].includes(framework)) {
|
|
144
|
+
return {
|
|
145
|
+
ecosystem: 'Python',
|
|
146
|
+
missing: 'a pre-installed virtualenv / site-packages (artifacts only ship requirements.txt + source)',
|
|
147
|
+
installCmd: 'pip install',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (['laravel', 'symfony'].includes(framework)) {
|
|
151
|
+
return {
|
|
152
|
+
ecosystem: 'PHP',
|
|
153
|
+
missing: 'vendor/ (Composer dependencies are not packaged into the artifact)',
|
|
154
|
+
installCmd: 'composer install',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (framework === 'rails') {
|
|
158
|
+
return {
|
|
159
|
+
ecosystem: 'Ruby',
|
|
160
|
+
missing: 'vendor/bundle / installed gems (artifacts only ship Gemfile + lock)',
|
|
161
|
+
installCmd: 'bundle install',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ecosystem: 'backend',
|
|
166
|
+
missing: 'installed dependencies (not packaged into the artifact)',
|
|
167
|
+
installCmd: 'dependency install',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default {
|
|
172
|
+
resolveDockerImageRef,
|
|
173
|
+
generateFrontendRuntimeDockerfile,
|
|
174
|
+
generateSpringRuntimeDockerfile,
|
|
175
|
+
generateGoRuntimeDockerfile,
|
|
176
|
+
generateDotnetRuntimeDockerfile,
|
|
177
|
+
isFrontendStaticFramework,
|
|
178
|
+
isNodeBackendFramework,
|
|
179
|
+
isInterpretedBackendFramework,
|
|
180
|
+
describeInterpretedBackendGap,
|
|
181
|
+
};
|