@akash-chowdhury-24/deployhub 2.0.4 → 2.0.5
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/artifact/engine.js +13 -0
- package/src/commands/init.js +28 -2
- package/src/core/stages.js +13 -0
- package/src/deployment/deployment-env.js +9 -0
- package/src/deployment/providers/kubernetes.js +6 -4
- package/src/utils/dockerfile.js +486 -0
- package/src/utils/github-actions.js +56 -1
- package/src/utils/kubernetes-manifests.js +168 -0
- package/src/utils/scaffold.js +192 -0
package/README.md
CHANGED
|
@@ -185,6 +185,8 @@ The wizard asks the same core questions for every setup:
|
|
|
185
185
|
- `.github/workflows/deployhub.yml` — CI pipeline
|
|
186
186
|
- `.env.example` — list of env vars you may need
|
|
187
187
|
- `nginx.conf` — auto-generated if frontend deploys to SSH
|
|
188
|
+
- `Dockerfile` — auto-generated if missing and you chose Docker or Kubernetes deploy (your existing `Dockerfile` is never overwritten)
|
|
189
|
+
- `k8s/deployment.yaml` and `k8s/service.yaml` — auto-generated if missing and you chose Kubernetes deploy (existing manifests are never overwritten)
|
|
188
190
|
|
|
189
191
|
---
|
|
190
192
|
|
|
@@ -596,7 +598,7 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
|
|
|
596
598
|
| **ec2** | AWS users with an existing EC2 instance | Running EC2 instance, security group, key pair |
|
|
597
599
|
| **azure-vm** | Azure users with an existing virtual machine | Running Azure VM, NSG allowing SSH |
|
|
598
600
|
| **gcp-vm** | GCP users with an existing Compute Engine VM | Running VM, firewall rule for SSH, metadata SSH key |
|
|
599
|
-
| **kubernetes** | Teams with an existing K8s cluster | Cluster, kubectl access
|
|
601
|
+
| **kubernetes** | Teams with an existing K8s cluster | Cluster, kubectl access; manifests auto-generated if missing |
|
|
600
602
|
|
|
601
603
|
---
|
|
602
604
|
|
|
@@ -675,10 +677,11 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
675
677
|
|
|
676
678
|
**Prerequisites:**
|
|
677
679
|
- [ ] Docker installed (`docker --version` works)
|
|
678
|
-
- [ ] `Dockerfile` or `docker-compose.yml` in project
|
|
679
680
|
- [ ] Registry account if pushing private images
|
|
681
|
+
- [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
|
|
680
682
|
|
|
681
683
|
**What DeployHub automates:**
|
|
684
|
+
- Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
|
|
682
685
|
- `.env.example` for image name, registry, remote `DOCKER_HOST`
|
|
683
686
|
- Docker daemon connectivity test during `init`
|
|
684
687
|
- `docker compose up` or build/push/run during deploy
|
|
@@ -812,11 +815,12 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
812
815
|
|
|
813
816
|
**Prerequisites:**
|
|
814
817
|
- [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
|
|
815
|
-
- [ ] `kubectl` installed and configured
|
|
816
|
-
- [ ] Kubernetes manifests (`.yaml` or `k8s/` directory) in your repo
|
|
818
|
+
- [ ] `kubectl` installed and configured on your **local machine** (for `deployhub doctor` / manual `deployhub deploy`)
|
|
817
819
|
- [ ] Cluster reachable from CI (kubeconfig secret or cloud auth)
|
|
818
820
|
|
|
819
821
|
**What DeployHub automates:**
|
|
822
|
+
- Starter `k8s/deployment.yaml` and `k8s/service.yaml` when no manifests exist (skipped if you already have a `k8s/` directory or root-level Kubernetes YAML files)
|
|
823
|
+
- GitHub Actions installs `kubectl` on the CI runner and writes kubeconfig from secrets (no local `kubectl` required for the automated push-to-main deploy path)
|
|
820
824
|
- Lists `kubectl` contexts during `init` for easy selection
|
|
821
825
|
- Auto-detects `~/.kube/config`
|
|
822
826
|
- Complete `.env.example` for kubeconfig, context, namespace
|
|
@@ -835,6 +839,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
835
839
|
| `KUBE_CONTEXT` | Context name | `my-cluster` | `kubectl config get-contexts` |
|
|
836
840
|
| `KUBE_NAMESPACE` | Target namespace | `my-app` | `kubectl get namespaces` |
|
|
837
841
|
| `DOCKER_IMAGE_NAME` | Container image | `ghcr.io/org/app` | Your registry |
|
|
842
|
+
| `DOCKER_IMAGE_TAG` | Image tag | `1.0.0` or `latest` | Project version or your choice |
|
|
838
843
|
| `KUBE_IMAGE_PULL_SECRET` | Pull secret name | `regcred` | `kubectl create secret docker-registry` |
|
|
839
844
|
|
|
840
845
|
---
|
package/package.json
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -6,6 +6,11 @@ import { createLogger } from '../logger/index.js';
|
|
|
6
6
|
import { generateChecksums, formatChecksums } from '../utils/checksums.js';
|
|
7
7
|
import { getProjectVersion } from '../utils/version.js';
|
|
8
8
|
import { generateNginxConfig } from '../utils/nginx.js';
|
|
9
|
+
import {
|
|
10
|
+
ensureDeployScaffold,
|
|
11
|
+
copyKubernetesManifestsIfPresent,
|
|
12
|
+
copyDeployAssetsToArtifactDir,
|
|
13
|
+
} from '../utils/scaffold.js';
|
|
9
14
|
import { getGeneratedByMetadata, getArtifactReadmeFooter } from '../utils/author.js';
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -143,6 +148,8 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
|
|
|
143
148
|
await copyIfExists(cwd, stagingDir, file);
|
|
144
149
|
}
|
|
145
150
|
|
|
151
|
+
await copyKubernetesManifestsIfPresent(cwd, stagingDir);
|
|
152
|
+
|
|
146
153
|
const hasSshDeploy = (config.deploy || []).some(
|
|
147
154
|
(envName) => config.environments[envName]?.type === 'ssh'
|
|
148
155
|
);
|
|
@@ -172,6 +179,8 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
172
179
|
await copyIfExists(cwd, stagingDir, file);
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
await copyKubernetesManifestsIfPresent(cwd, stagingDir);
|
|
183
|
+
|
|
175
184
|
await copyDirectoryIfExists(cwd, stagingDir, 'config');
|
|
176
185
|
await copyDirectoryIfExists(cwd, stagingDir, 'migrations');
|
|
177
186
|
|
|
@@ -259,6 +268,8 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
259
268
|
|
|
260
269
|
log.info(`Staging ${projectType} artifact...`);
|
|
261
270
|
|
|
271
|
+
await ensureDeployScaffold(cwd, config, config.environments || {}, { silent: false });
|
|
272
|
+
|
|
262
273
|
if (projectType === 'both') {
|
|
263
274
|
await stageFrontendArtifact(cwd, stagingDir, config);
|
|
264
275
|
const backendStaging = path.join(stagingDir, 'backend');
|
|
@@ -337,6 +348,8 @@ ${getArtifactReadmeFooter()}`;
|
|
|
337
348
|
const zipPath = path.join(artifactDir, 'artifact.zip');
|
|
338
349
|
await createZip(stagingDir, zipPath);
|
|
339
350
|
|
|
351
|
+
await copyDeployAssetsToArtifactDir(stagingDir, artifactDir);
|
|
352
|
+
|
|
340
353
|
const checksums = await generateChecksums(stagingDir);
|
|
341
354
|
const checksumContent = formatChecksums(checksums);
|
|
342
355
|
await fs.writeFile(path.join(artifactDir, 'checksums.txt'), checksumContent);
|
package/src/commands/init.js
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
} from '../utils/github-actions.js';
|
|
21
21
|
import { printAuthorFooter } from '../utils/author.js';
|
|
22
22
|
import { generateNginxConfig } from '../utils/nginx.js';
|
|
23
|
+
import {
|
|
24
|
+
ensureDockerfile,
|
|
25
|
+
ensureKubernetesManifests,
|
|
26
|
+
} from '../utils/scaffold.js';
|
|
23
27
|
import {
|
|
24
28
|
promptServerDeployment,
|
|
25
29
|
buildServerEnvEntry,
|
|
@@ -190,6 +194,21 @@ async function generateProjectScaffold(config, environments, cwd) {
|
|
|
190
194
|
await fs.writeFile(path.join(cwd, 'nginx.conf'), nginxConf);
|
|
191
195
|
console.log(chalk.gray(' • nginx.conf (auto-generated)'));
|
|
192
196
|
}
|
|
197
|
+
|
|
198
|
+
const dockerResult = await ensureDockerfile(cwd, config);
|
|
199
|
+
if (dockerResult.generated) {
|
|
200
|
+
console.log(chalk.gray(' • Dockerfile (auto-generated)'));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const k8sResult = await ensureKubernetesManifests(cwd, config, environments);
|
|
204
|
+
if (k8sResult.generated) {
|
|
205
|
+
console.log(chalk.gray(' • k8s/deployment.yaml, k8s/service.yaml (auto-generated)'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
dockerfileGenerated: dockerResult.generated,
|
|
210
|
+
kubernetesGenerated: k8sResult.generated,
|
|
211
|
+
};
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
/**
|
|
@@ -397,7 +416,7 @@ export function registerInitCommand(program) {
|
|
|
397
416
|
}
|
|
398
417
|
|
|
399
418
|
const version = await getProjectVersion(cwd);
|
|
400
|
-
|
|
419
|
+
let hasDocker =
|
|
401
420
|
(detectedFrontend?.hasDocker || detectedBackend?.hasDocker) ?? false;
|
|
402
421
|
|
|
403
422
|
/** @type {Record<string, unknown>} */
|
|
@@ -458,7 +477,14 @@ export function registerInitCommand(program) {
|
|
|
458
477
|
config
|
|
459
478
|
);
|
|
460
479
|
|
|
461
|
-
await generateProjectScaffold(config, environments, cwd);
|
|
480
|
+
const scaffoldResult = await generateProjectScaffold(config, environments, cwd);
|
|
481
|
+
|
|
482
|
+
if (scaffoldResult?.dockerfileGenerated) {
|
|
483
|
+
hasDocker = true;
|
|
484
|
+
config.docker = true;
|
|
485
|
+
config.pipeline.docker = true;
|
|
486
|
+
await saveConfig(config, cwd);
|
|
487
|
+
}
|
|
462
488
|
|
|
463
489
|
const envExampleDest = path.join(cwd, '.env.example');
|
|
464
490
|
const envExampleContent = generateEnvExampleContent(
|
package/src/core/stages.js
CHANGED
|
@@ -6,6 +6,7 @@ import { deployToAll } from '../deployment/index.js';
|
|
|
6
6
|
import { sendNotifications } from '../notifications/index.js';
|
|
7
7
|
import axios from 'axios';
|
|
8
8
|
import { getProjectVersion } from '../utils/version.js';
|
|
9
|
+
import { ensureDeployScaffold } from '../utils/scaffold.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {import('../core/config.js').DeployHubConfig} config
|
|
@@ -40,6 +41,18 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
40
41
|
ctx.config.port = detected.port;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
const scaffold = await ensureDeployScaffold(
|
|
45
|
+
ctx.cwd,
|
|
46
|
+
ctx.config,
|
|
47
|
+
ctx.config.environments || {},
|
|
48
|
+
{ silent: false }
|
|
49
|
+
);
|
|
50
|
+
if (scaffold.dockerfile) {
|
|
51
|
+
ctx.config.docker = true;
|
|
52
|
+
if (ctx.config.pipeline) {
|
|
53
|
+
ctx.config.pipeline.docker = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
43
56
|
ctx.state.framework = ctx.config.framework;
|
|
44
57
|
ctx.state.projectType = ctx.config.projectType || 'frontend';
|
|
45
58
|
},
|
|
@@ -323,6 +323,15 @@ export const DEPLOYMENT_ENV_DEFS = {
|
|
|
323
323
|
example: 'ghcr.io/myorg/myapp',
|
|
324
324
|
when: 'optional',
|
|
325
325
|
},
|
|
326
|
+
{
|
|
327
|
+
key: 'DOCKER_IMAGE_TAG',
|
|
328
|
+
optionalReason: 'defaults to your project version, then "latest" if unset',
|
|
329
|
+
comment: [
|
|
330
|
+
'Image tag written into generated manifests and used at deploy time.',
|
|
331
|
+
],
|
|
332
|
+
example: 'latest',
|
|
333
|
+
when: 'optional',
|
|
334
|
+
},
|
|
326
335
|
{
|
|
327
336
|
key: 'KUBE_IMAGE_PULL_SECRET',
|
|
328
337
|
optionalReason: 'only required when pulling from a private container registry',
|
|
@@ -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,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dockerfile generation based on detected framework / project settings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const GENERATED_HEADER =
|
|
6
|
+
'# Generated by DeployHub — review exposed port and start command before deploying.\n';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} projectName
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeDockerProjectName(projectName) {
|
|
13
|
+
return projectName.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
18
|
+
* @returns {{ framework: string, buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number, projectType: string }}
|
|
19
|
+
*/
|
|
20
|
+
export function resolveDockerSettings(config) {
|
|
21
|
+
const projectType = config.projectType || 'frontend';
|
|
22
|
+
|
|
23
|
+
if (projectType === 'both' && config.backend) {
|
|
24
|
+
return {
|
|
25
|
+
framework: config.backend.framework || 'express',
|
|
26
|
+
buildCommand: config.backend.buildCommand ?? null,
|
|
27
|
+
buildOutput: config.backend.buildOutput || '.',
|
|
28
|
+
startCommand: config.backend.startCommand || 'npm start',
|
|
29
|
+
port: config.backend.port || 3000,
|
|
30
|
+
projectType,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
framework: config.framework || 'express',
|
|
36
|
+
buildCommand: config.buildCommand ?? null,
|
|
37
|
+
buildOutput: config.buildOutput || 'dist',
|
|
38
|
+
startCommand: config.startCommand || 'npm start',
|
|
39
|
+
port: config.port || 3000,
|
|
40
|
+
projectType,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function generateDockerfile(config) {
|
|
49
|
+
const settings = resolveDockerSettings(config);
|
|
50
|
+
const { framework } = settings;
|
|
51
|
+
|
|
52
|
+
const frontendStatic = ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'];
|
|
53
|
+
if (frontendStatic.includes(framework)) {
|
|
54
|
+
return generateFrontendStaticDockerfile(settings);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
switch (framework) {
|
|
58
|
+
case 'nextjs':
|
|
59
|
+
return generateNextjsDockerfile(settings);
|
|
60
|
+
case 'nestjs':
|
|
61
|
+
return generateNestjsDockerfile(settings);
|
|
62
|
+
case 'express':
|
|
63
|
+
case 'fastify':
|
|
64
|
+
case 'koa':
|
|
65
|
+
return generateNodeBackendDockerfile(settings);
|
|
66
|
+
case 'fastapi':
|
|
67
|
+
return generateFastapiDockerfile(settings);
|
|
68
|
+
case 'django':
|
|
69
|
+
return generateDjangoDockerfile(settings);
|
|
70
|
+
case 'flask':
|
|
71
|
+
return generateFlaskDockerfile(settings);
|
|
72
|
+
case 'laravel':
|
|
73
|
+
return generateLaravelDockerfile(settings);
|
|
74
|
+
case 'symfony':
|
|
75
|
+
return generateSymfonyDockerfile(settings);
|
|
76
|
+
case 'spring':
|
|
77
|
+
return generateSpringDockerfile(settings);
|
|
78
|
+
case 'go':
|
|
79
|
+
return generateGoDockerfile(settings);
|
|
80
|
+
case 'dotnet':
|
|
81
|
+
return generateDotnetDockerfile(settings);
|
|
82
|
+
case 'rails':
|
|
83
|
+
return generateRailsDockerfile(settings);
|
|
84
|
+
default:
|
|
85
|
+
return generateNodeBackendDockerfile(settings);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {{ buildCommand: string|null, buildOutput: string, port: number }} settings
|
|
91
|
+
*/
|
|
92
|
+
function generateFrontendStaticDockerfile(settings) {
|
|
93
|
+
const buildCmd = settings.buildCommand || 'npm run build';
|
|
94
|
+
const output = settings.buildOutput || 'dist';
|
|
95
|
+
|
|
96
|
+
return `${GENERATED_HEADER}
|
|
97
|
+
FROM node:20-alpine AS build
|
|
98
|
+
WORKDIR /app
|
|
99
|
+
COPY package*.json ./
|
|
100
|
+
RUN npm ci
|
|
101
|
+
COPY . .
|
|
102
|
+
RUN ${buildCmd}
|
|
103
|
+
|
|
104
|
+
FROM nginx:alpine
|
|
105
|
+
COPY --from=build /app/${output} /usr/share/nginx/html
|
|
106
|
+
EXPOSE 80
|
|
107
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {{ buildCommand: string|null, startCommand: string|null, port: number }} settings
|
|
113
|
+
*/
|
|
114
|
+
function generateNextjsDockerfile(settings) {
|
|
115
|
+
const buildCmd = settings.buildCommand || 'npm run build';
|
|
116
|
+
const startCmd = settings.startCommand || 'npm start';
|
|
117
|
+
const port = settings.port || 3000;
|
|
118
|
+
const startParts = parseCommand(startCmd);
|
|
119
|
+
|
|
120
|
+
return `${GENERATED_HEADER}
|
|
121
|
+
FROM node:20-alpine AS deps
|
|
122
|
+
WORKDIR /app
|
|
123
|
+
COPY package*.json ./
|
|
124
|
+
RUN npm ci
|
|
125
|
+
|
|
126
|
+
FROM node:20-alpine AS build
|
|
127
|
+
WORKDIR /app
|
|
128
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
129
|
+
COPY . .
|
|
130
|
+
RUN ${buildCmd}
|
|
131
|
+
|
|
132
|
+
FROM node:20-alpine AS runner
|
|
133
|
+
WORKDIR /app
|
|
134
|
+
ENV NODE_ENV=production
|
|
135
|
+
COPY --from=build /app/package*.json ./
|
|
136
|
+
COPY --from=build /app/node_modules ./node_modules
|
|
137
|
+
COPY --from=build /app/.next ./.next
|
|
138
|
+
COPY --from=build /app/public ./public
|
|
139
|
+
EXPOSE ${port}
|
|
140
|
+
CMD ${JSON.stringify(startParts)}
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
|
|
146
|
+
*/
|
|
147
|
+
function generateNestjsDockerfile(settings) {
|
|
148
|
+
const buildCmd = settings.buildCommand || 'npm run build';
|
|
149
|
+
const startCmd = settings.startCommand || 'node dist/main';
|
|
150
|
+
const port = settings.port || 3000;
|
|
151
|
+
const startParts = parseCommand(startCmd);
|
|
152
|
+
|
|
153
|
+
return `${GENERATED_HEADER}
|
|
154
|
+
FROM node:20-alpine AS build
|
|
155
|
+
WORKDIR /app
|
|
156
|
+
COPY package*.json ./
|
|
157
|
+
RUN npm ci
|
|
158
|
+
COPY . .
|
|
159
|
+
RUN ${buildCmd}
|
|
160
|
+
|
|
161
|
+
FROM node:20-alpine
|
|
162
|
+
WORKDIR /app
|
|
163
|
+
ENV NODE_ENV=production
|
|
164
|
+
COPY package*.json ./
|
|
165
|
+
RUN npm ci --omit=dev
|
|
166
|
+
COPY --from=build /app/${settings.buildOutput || 'dist'} ./${settings.buildOutput || 'dist'}
|
|
167
|
+
EXPOSE ${port}
|
|
168
|
+
CMD ${JSON.stringify(startParts)}
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
|
|
174
|
+
*/
|
|
175
|
+
function generateNodeBackendDockerfile(settings) {
|
|
176
|
+
const port = settings.port || 3000;
|
|
177
|
+
const startCmd = settings.startCommand || 'npm start';
|
|
178
|
+
const startParts = parseCommand(startCmd);
|
|
179
|
+
|
|
180
|
+
if (settings.buildCommand) {
|
|
181
|
+
return `${GENERATED_HEADER}
|
|
182
|
+
FROM node:20-alpine AS build
|
|
183
|
+
WORKDIR /app
|
|
184
|
+
COPY package*.json ./
|
|
185
|
+
RUN npm ci
|
|
186
|
+
COPY . .
|
|
187
|
+
RUN ${settings.buildCommand}
|
|
188
|
+
|
|
189
|
+
FROM node:20-alpine
|
|
190
|
+
WORKDIR /app
|
|
191
|
+
ENV NODE_ENV=production
|
|
192
|
+
COPY package*.json ./
|
|
193
|
+
RUN npm ci --omit=dev
|
|
194
|
+
COPY --from=build /app/${settings.buildOutput || '.'} ./${settings.buildOutput || '.'}
|
|
195
|
+
EXPOSE ${port}
|
|
196
|
+
CMD ${JSON.stringify(startParts)}
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return `${GENERATED_HEADER}
|
|
201
|
+
FROM node:20-alpine AS deps
|
|
202
|
+
WORKDIR /app
|
|
203
|
+
COPY package*.json ./
|
|
204
|
+
RUN npm ci --omit=dev
|
|
205
|
+
|
|
206
|
+
FROM node:20-alpine
|
|
207
|
+
WORKDIR /app
|
|
208
|
+
ENV NODE_ENV=production
|
|
209
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
210
|
+
COPY . .
|
|
211
|
+
EXPOSE ${port}
|
|
212
|
+
CMD ${JSON.stringify(startParts)}
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {{ startCommand: string|null, port: number }} settings
|
|
218
|
+
*/
|
|
219
|
+
function generateFastapiDockerfile(settings) {
|
|
220
|
+
const port = settings.port || 8000;
|
|
221
|
+
const startCmd = settings.startCommand || `uvicorn main:app --host 0.0.0.0 --port ${port}`;
|
|
222
|
+
const startParts = parseCommand(startCmd);
|
|
223
|
+
|
|
224
|
+
return `${GENERATED_HEADER}
|
|
225
|
+
FROM python:3.11-slim AS build
|
|
226
|
+
WORKDIR /app
|
|
227
|
+
COPY requirements.txt ./
|
|
228
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
229
|
+
|
|
230
|
+
FROM python:3.11-slim
|
|
231
|
+
WORKDIR /app
|
|
232
|
+
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
|
233
|
+
COPY --from=build /usr/local/bin /usr/local/bin
|
|
234
|
+
COPY . .
|
|
235
|
+
EXPOSE ${port}
|
|
236
|
+
CMD ${JSON.stringify(startParts)}
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {{ startCommand: string|null, port: number }} settings
|
|
242
|
+
*/
|
|
243
|
+
function generateDjangoDockerfile(settings) {
|
|
244
|
+
const port = settings.port || 8000;
|
|
245
|
+
const startCmd =
|
|
246
|
+
settings.startCommand || `gunicorn config.wsgi:application --bind 0.0.0.0:${port}`;
|
|
247
|
+
const startParts = parseCommand(startCmd);
|
|
248
|
+
|
|
249
|
+
return `${GENERATED_HEADER}
|
|
250
|
+
FROM python:3.11-slim AS build
|
|
251
|
+
WORKDIR /app
|
|
252
|
+
COPY requirements.txt ./
|
|
253
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
254
|
+
|
|
255
|
+
FROM python:3.11-slim
|
|
256
|
+
WORKDIR /app
|
|
257
|
+
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
|
258
|
+
COPY --from=build /usr/local/bin /usr/local/bin
|
|
259
|
+
COPY . .
|
|
260
|
+
EXPOSE ${port}
|
|
261
|
+
CMD ${JSON.stringify(startParts)}
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {{ startCommand: string|null, port: number }} settings
|
|
267
|
+
*/
|
|
268
|
+
function generateFlaskDockerfile(settings) {
|
|
269
|
+
const port = settings.port || 5000;
|
|
270
|
+
const startCmd = settings.startCommand || `gunicorn app:app --bind 0.0.0.0:${port}`;
|
|
271
|
+
const startParts = parseCommand(startCmd);
|
|
272
|
+
|
|
273
|
+
return `${GENERATED_HEADER}
|
|
274
|
+
FROM python:3.11-slim AS build
|
|
275
|
+
WORKDIR /app
|
|
276
|
+
COPY requirements.txt ./
|
|
277
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
278
|
+
|
|
279
|
+
FROM python:3.11-slim
|
|
280
|
+
WORKDIR /app
|
|
281
|
+
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
|
282
|
+
COPY --from=build /usr/local/bin /usr/local/bin
|
|
283
|
+
COPY . .
|
|
284
|
+
EXPOSE ${port}
|
|
285
|
+
CMD ${JSON.stringify(startParts)}
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {{ port: number }} settings
|
|
291
|
+
*/
|
|
292
|
+
function generateLaravelDockerfile(settings) {
|
|
293
|
+
const port = settings.port || 80;
|
|
294
|
+
|
|
295
|
+
return `${GENERATED_HEADER}
|
|
296
|
+
FROM composer:2 AS vendor
|
|
297
|
+
WORKDIR /app
|
|
298
|
+
COPY composer.json composer.lock ./
|
|
299
|
+
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
|
300
|
+
|
|
301
|
+
FROM php:8.2-fpm-alpine
|
|
302
|
+
WORKDIR /var/www/html
|
|
303
|
+
COPY --from=vendor /app/vendor ./vendor
|
|
304
|
+
COPY . .
|
|
305
|
+
RUN chown -R www-data:www-data storage bootstrap/cache || true
|
|
306
|
+
EXPOSE ${port}
|
|
307
|
+
CMD ["php-fpm"]
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {{ port: number }} settings
|
|
313
|
+
*/
|
|
314
|
+
function generateSymfonyDockerfile(settings) {
|
|
315
|
+
const port = settings.port || 80;
|
|
316
|
+
|
|
317
|
+
return `${GENERATED_HEADER}
|
|
318
|
+
FROM composer:2 AS vendor
|
|
319
|
+
WORKDIR /app
|
|
320
|
+
COPY composer.json composer.lock ./
|
|
321
|
+
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
|
322
|
+
|
|
323
|
+
FROM php:8.2-fpm-alpine
|
|
324
|
+
WORKDIR /var/www/html
|
|
325
|
+
COPY --from=vendor /app/vendor ./vendor
|
|
326
|
+
COPY . .
|
|
327
|
+
EXPOSE ${port}
|
|
328
|
+
CMD ["php-fpm"]
|
|
329
|
+
`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {{ buildCommand: string|null, port: number }} settings
|
|
334
|
+
*/
|
|
335
|
+
function generateSpringDockerfile(settings) {
|
|
336
|
+
const buildCmd = settings.buildCommand || 'mvn package -DskipTests';
|
|
337
|
+
const port = settings.port || 8080;
|
|
338
|
+
|
|
339
|
+
return `${GENERATED_HEADER}
|
|
340
|
+
FROM eclipse-temurin:17-jdk-alpine AS build
|
|
341
|
+
WORKDIR /app
|
|
342
|
+
COPY pom.xml ./
|
|
343
|
+
COPY src ./src
|
|
344
|
+
RUN apk add --no-cache maven && ${buildCmd}
|
|
345
|
+
|
|
346
|
+
FROM eclipse-temurin:17-jre-alpine
|
|
347
|
+
WORKDIR /app
|
|
348
|
+
COPY --from=build /app/target/*.jar app.jar
|
|
349
|
+
EXPOSE ${port}
|
|
350
|
+
CMD ["java", "-jar", "app.jar"]
|
|
351
|
+
`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {{ buildCommand: string|null, startCommand: string|null, port: number }} settings
|
|
356
|
+
*/
|
|
357
|
+
function generateGoDockerfile(settings) {
|
|
358
|
+
const buildCmd = settings.buildCommand || 'go build -o /app/bin/app .';
|
|
359
|
+
const port = settings.port || 8080;
|
|
360
|
+
|
|
361
|
+
return `${GENERATED_HEADER}
|
|
362
|
+
FROM golang:1.22-alpine AS build
|
|
363
|
+
WORKDIR /app
|
|
364
|
+
COPY go.mod go.sum ./
|
|
365
|
+
RUN go mod download
|
|
366
|
+
COPY . .
|
|
367
|
+
RUN ${buildCmd}
|
|
368
|
+
|
|
369
|
+
FROM alpine:3.19
|
|
370
|
+
WORKDIR /app
|
|
371
|
+
RUN apk add --no-cache ca-certificates
|
|
372
|
+
COPY --from=build /app/bin/app ./app
|
|
373
|
+
EXPOSE ${port}
|
|
374
|
+
CMD ["./app"]
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {{ buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number }} settings
|
|
380
|
+
*/
|
|
381
|
+
function generateDotnetDockerfile(settings) {
|
|
382
|
+
const buildCmd = settings.buildCommand || 'dotnet publish -c Release -o /app/publish';
|
|
383
|
+
const port = settings.port || 5000;
|
|
384
|
+
const output = settings.buildOutput || 'publish';
|
|
385
|
+
|
|
386
|
+
return `${GENERATED_HEADER}
|
|
387
|
+
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
|
388
|
+
WORKDIR /src
|
|
389
|
+
COPY *.csproj ./
|
|
390
|
+
RUN dotnet restore
|
|
391
|
+
COPY . .
|
|
392
|
+
RUN ${buildCmd}
|
|
393
|
+
|
|
394
|
+
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
|
395
|
+
WORKDIR /app
|
|
396
|
+
COPY --from=build /src/${output} .
|
|
397
|
+
EXPOSE ${port}
|
|
398
|
+
ENV ASPNETCORE_URLS=http://+:${port}
|
|
399
|
+
CMD ["dotnet", "App.dll"]
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* @param {{ startCommand: string|null, port: number }} settings
|
|
405
|
+
*/
|
|
406
|
+
function generateRailsDockerfile(settings) {
|
|
407
|
+
const port = settings.port || 3000;
|
|
408
|
+
const startCmd = settings.startCommand || 'bundle exec puma -C config/puma.rb';
|
|
409
|
+
const startParts = parseCommand(startCmd);
|
|
410
|
+
|
|
411
|
+
return `${GENERATED_HEADER}
|
|
412
|
+
FROM ruby:3.2-slim AS build
|
|
413
|
+
WORKDIR /app
|
|
414
|
+
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
|
|
415
|
+
COPY Gemfile Gemfile.lock ./
|
|
416
|
+
RUN bundle install --without development test
|
|
417
|
+
COPY . .
|
|
418
|
+
RUN bundle exec rake assets:precompile || true
|
|
419
|
+
|
|
420
|
+
FROM ruby:3.2-slim
|
|
421
|
+
WORKDIR /app
|
|
422
|
+
RUN apt-get update -qq && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/*
|
|
423
|
+
COPY --from=build /usr/local/bundle /usr/local/bundle
|
|
424
|
+
COPY --from=build /app .
|
|
425
|
+
EXPOSE ${port}
|
|
426
|
+
CMD ${JSON.stringify(startParts)}
|
|
427
|
+
`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @param {string} command
|
|
432
|
+
* @returns {string[]}
|
|
433
|
+
*/
|
|
434
|
+
function parseCommand(command) {
|
|
435
|
+
const trimmed = command.trim();
|
|
436
|
+
if (trimmed.startsWith('[')) {
|
|
437
|
+
try {
|
|
438
|
+
return JSON.parse(trimmed);
|
|
439
|
+
} catch {
|
|
440
|
+
// fall through
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const match = trimmed.match(/^(\S+)(?:\s+(.*))?$/);
|
|
445
|
+
if (!match) return ['sh', '-c', trimmed];
|
|
446
|
+
const [, bin, rest] = match;
|
|
447
|
+
if (!rest) return [bin];
|
|
448
|
+
return [bin, ...rest.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g).map((part) => part.replace(/^['"]|['"]$/g, ''))];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Human-readable framework label for user messages.
|
|
453
|
+
* @param {string} framework
|
|
454
|
+
*/
|
|
455
|
+
export function getDockerfileFrameworkLabel(framework) {
|
|
456
|
+
const labels = {
|
|
457
|
+
react: 'React',
|
|
458
|
+
vue: 'Vue',
|
|
459
|
+
angular: 'Angular',
|
|
460
|
+
nextjs: 'Next.js',
|
|
461
|
+
svelte: 'Svelte',
|
|
462
|
+
astro: 'Astro',
|
|
463
|
+
vanilla: 'Vanilla JS',
|
|
464
|
+
express: 'Express',
|
|
465
|
+
nestjs: 'NestJS',
|
|
466
|
+
fastify: 'Fastify',
|
|
467
|
+
koa: 'Koa',
|
|
468
|
+
fastapi: 'FastAPI',
|
|
469
|
+
django: 'Django',
|
|
470
|
+
flask: 'Flask',
|
|
471
|
+
laravel: 'Laravel',
|
|
472
|
+
symfony: 'Symfony',
|
|
473
|
+
spring: 'Spring Boot',
|
|
474
|
+
go: 'Go',
|
|
475
|
+
dotnet: '.NET',
|
|
476
|
+
rails: 'Ruby on Rails',
|
|
477
|
+
};
|
|
478
|
+
return labels[framework] || framework;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export default {
|
|
482
|
+
generateDockerfile,
|
|
483
|
+
resolveDockerSettings,
|
|
484
|
+
sanitizeDockerProjectName,
|
|
485
|
+
getDockerfileFrameworkLabel,
|
|
486
|
+
};
|
|
@@ -208,6 +208,56 @@ function getBackendSetupSteps(config) {
|
|
|
208
208
|
return steps;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
const KUBECTL_VERSION = 'v1.30.4';
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string[]} deployEnvironments
|
|
215
|
+
* @param {Record<string, { type: string }>} environments
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
function hasKubernetesDeploy(deployEnvironments, environments) {
|
|
219
|
+
return deployEnvironments.some((envName) => environments[envName]?.type === 'kubernetes');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
function getKubernetesSetupSteps() {
|
|
226
|
+
return ` - name: Setup kubectl
|
|
227
|
+
uses: azure/setup-kubectl@v4
|
|
228
|
+
with:
|
|
229
|
+
version: '${KUBECTL_VERSION}'
|
|
230
|
+
|
|
231
|
+
- name: Configure kubeconfig
|
|
232
|
+
env:
|
|
233
|
+
KUBECONFIG_SECRET: \${{ secrets.KUBECONFIG }}
|
|
234
|
+
run: |
|
|
235
|
+
mkdir -p "$GITHUB_WORKSPACE/.kube"
|
|
236
|
+
if echo "$KUBECONFIG_SECRET" | base64 -d > "$GITHUB_WORKSPACE/.kube/config" 2>/dev/null; then
|
|
237
|
+
:
|
|
238
|
+
else
|
|
239
|
+
printf '%s' "$KUBECONFIG_SECRET" > "$GITHUB_WORKSPACE/.kube/config"
|
|
240
|
+
fi
|
|
241
|
+
chmod 600 "$GITHUB_WORKSPACE/.kube/config"`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {string[]} deployEnvironments
|
|
246
|
+
* @param {Record<string, { type: string }>} environments
|
|
247
|
+
* @param {Set<string>} envVars
|
|
248
|
+
*/
|
|
249
|
+
function applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars) {
|
|
250
|
+
if (!hasKubernetesDeploy(deployEnvironments, environments)) return;
|
|
251
|
+
|
|
252
|
+
for (const entry of envVars) {
|
|
253
|
+
if (entry.startsWith('KUBECONFIG:')) {
|
|
254
|
+
envVars.delete(entry);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
envVars.add('KUBECONFIG: ${{ github.workspace }}/.kube/config');
|
|
259
|
+
}
|
|
260
|
+
|
|
211
261
|
/**
|
|
212
262
|
* @param {string[]} storageProviders
|
|
213
263
|
* @param {string[]} deployEnvironments
|
|
@@ -243,6 +293,8 @@ export function generateWorkflowYaml(
|
|
|
243
293
|
}
|
|
244
294
|
}
|
|
245
295
|
|
|
296
|
+
applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars);
|
|
297
|
+
|
|
246
298
|
const envBlock = Array.from(envVars)
|
|
247
299
|
.map((line) => ` ${line}`)
|
|
248
300
|
.join('\n');
|
|
@@ -253,6 +305,9 @@ export function generateWorkflowYaml(
|
|
|
253
305
|
const githubGitConfigStep = isGithubCliSource(cliSource)
|
|
254
306
|
? `${getGithubGitConfigStep()}\n`
|
|
255
307
|
: '';
|
|
308
|
+
const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
|
|
309
|
+
? `${getKubernetesSetupSteps()}\n`
|
|
310
|
+
: '';
|
|
256
311
|
|
|
257
312
|
const projectType = config?.projectType || 'frontend';
|
|
258
313
|
const installDepsCommand =
|
|
@@ -272,7 +327,7 @@ jobs:
|
|
|
272
327
|
${uniqueBackendSteps ? `${uniqueBackendSteps}\n` : ''} - uses: actions/setup-node@v4
|
|
273
328
|
with:
|
|
274
329
|
node-version: '20'
|
|
275
|
-
${githubGitConfigStep} - name: Install project dependencies
|
|
330
|
+
${kubernetesSteps}${githubGitConfigStep} - name: Install project dependencies
|
|
276
331
|
run: ${installDepsCommand}
|
|
277
332
|
- name: Install DeployHub CLI
|
|
278
333
|
run: npm install ${installSpec} --no-save
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} name
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeK8sName(name) {
|
|
9
|
+
const sanitized = String(name)
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
12
|
+
.replace(/^-+|-+$/g, '')
|
|
13
|
+
.slice(0, 63);
|
|
14
|
+
return sanitized || 'app';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} cwd
|
|
19
|
+
* @returns {Promise<boolean>}
|
|
20
|
+
*/
|
|
21
|
+
export async function hasKubernetesManifests(cwd) {
|
|
22
|
+
if (await fs.pathExists(path.join(cwd, 'k8s'))) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let files = [];
|
|
27
|
+
try {
|
|
28
|
+
files = await fs.readdir(cwd);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
if (!/\.ya?ml$/i.test(file)) continue;
|
|
35
|
+
const content = await fs.readFile(path.join(cwd, file), 'utf-8');
|
|
36
|
+
if (/^\s*apiVersion:/m.test(content) && /^\s*kind:/m.test(content)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} options
|
|
46
|
+
* @param {string} options.appName
|
|
47
|
+
* @param {string} options.imageName
|
|
48
|
+
* @param {string} [options.imageTag]
|
|
49
|
+
* @param {number} options.port
|
|
50
|
+
* @param {string} options.namespace
|
|
51
|
+
* @param {string} [options.imagePullSecret]
|
|
52
|
+
* @returns {{ deploymentYaml: string, serviceYaml: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function generateKubernetesManifests({
|
|
55
|
+
appName,
|
|
56
|
+
imageName,
|
|
57
|
+
imageTag = 'latest',
|
|
58
|
+
port,
|
|
59
|
+
namespace,
|
|
60
|
+
imagePullSecret = '',
|
|
61
|
+
}) {
|
|
62
|
+
const name = sanitizeK8sName(appName);
|
|
63
|
+
const image = imageName.includes(':') ? imageName : `${imageName}:${imageTag}`;
|
|
64
|
+
const pullSecretBlock = imagePullSecret
|
|
65
|
+
? ` imagePullSecrets:\n - name: ${imagePullSecret}\n`
|
|
66
|
+
: '';
|
|
67
|
+
|
|
68
|
+
const deploymentYaml = `apiVersion: apps/v1
|
|
69
|
+
kind: Deployment
|
|
70
|
+
metadata:
|
|
71
|
+
name: ${name}
|
|
72
|
+
namespace: ${namespace}
|
|
73
|
+
spec:
|
|
74
|
+
# Adjust replica count as needed
|
|
75
|
+
replicas: 1
|
|
76
|
+
selector:
|
|
77
|
+
matchLabels:
|
|
78
|
+
app: ${name}
|
|
79
|
+
template:
|
|
80
|
+
metadata:
|
|
81
|
+
labels:
|
|
82
|
+
app: ${name}
|
|
83
|
+
spec:
|
|
84
|
+
${pullSecretBlock} containers:
|
|
85
|
+
- name: ${name}
|
|
86
|
+
image: ${image}
|
|
87
|
+
ports:
|
|
88
|
+
- containerPort: ${port}
|
|
89
|
+
resources:
|
|
90
|
+
requests:
|
|
91
|
+
memory: "128Mi"
|
|
92
|
+
cpu: "100m"
|
|
93
|
+
limits:
|
|
94
|
+
memory: "512Mi"
|
|
95
|
+
cpu: "500m"
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const servicePort = port === 80 ? 80 : 80;
|
|
99
|
+
const targetPort = port;
|
|
100
|
+
|
|
101
|
+
const serviceYaml = `apiVersion: v1
|
|
102
|
+
kind: Service
|
|
103
|
+
metadata:
|
|
104
|
+
name: ${name}
|
|
105
|
+
namespace: ${namespace}
|
|
106
|
+
spec:
|
|
107
|
+
type: ClusterIP
|
|
108
|
+
selector:
|
|
109
|
+
app: ${name}
|
|
110
|
+
ports:
|
|
111
|
+
- port: ${servicePort}
|
|
112
|
+
targetPort: ${targetPort}
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
return { deploymentYaml, serviceYaml };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
120
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
121
|
+
* @returns {{ appName: string, imageName: string, imageTag: string, port: number, namespace: string, imagePullSecret: string }}
|
|
122
|
+
*/
|
|
123
|
+
export function resolveKubernetesManifestOptions(config, environments = {}) {
|
|
124
|
+
const envList = Object.values(environments);
|
|
125
|
+
const k8sEnv = envList.find((env) => env.type === 'kubernetes') || {};
|
|
126
|
+
|
|
127
|
+
const appName = config.project || 'app';
|
|
128
|
+
const imageName =
|
|
129
|
+
/** @type {string} */ (k8sEnv.dockerImageName) ||
|
|
130
|
+
process.env.DOCKER_IMAGE_NAME ||
|
|
131
|
+
appName;
|
|
132
|
+
const imageTag =
|
|
133
|
+
process.env.DOCKER_IMAGE_TAG || config.version || 'latest';
|
|
134
|
+
const namespace =
|
|
135
|
+
/** @type {string} */ (k8sEnv.kubeNamespace) ||
|
|
136
|
+
process.env.KUBE_NAMESPACE ||
|
|
137
|
+
appName ||
|
|
138
|
+
'default';
|
|
139
|
+
const imagePullSecret =
|
|
140
|
+
/** @type {string} */ (k8sEnv.kubeImagePullSecret) ||
|
|
141
|
+
process.env.KUBE_IMAGE_PULL_SECRET ||
|
|
142
|
+
'';
|
|
143
|
+
|
|
144
|
+
let port = 3000;
|
|
145
|
+
if (config.projectType === 'both' && config.backend?.port) {
|
|
146
|
+
port = config.backend.port;
|
|
147
|
+
} else if (config.port) {
|
|
148
|
+
port = config.port;
|
|
149
|
+
} else if (config.backend?.port) {
|
|
150
|
+
port = config.backend.port;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
appName,
|
|
155
|
+
imageName,
|
|
156
|
+
imageTag,
|
|
157
|
+
port,
|
|
158
|
+
namespace,
|
|
159
|
+
imagePullSecret,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default {
|
|
164
|
+
sanitizeK8sName,
|
|
165
|
+
hasKubernetesManifests,
|
|
166
|
+
generateKubernetesManifests,
|
|
167
|
+
resolveKubernetesManifestOptions,
|
|
168
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import {
|
|
5
|
+
generateDockerfile,
|
|
6
|
+
getDockerfileFrameworkLabel,
|
|
7
|
+
resolveDockerSettings,
|
|
8
|
+
} from './dockerfile.js';
|
|
9
|
+
import {
|
|
10
|
+
generateKubernetesManifests,
|
|
11
|
+
hasKubernetesManifests,
|
|
12
|
+
resolveKubernetesManifestOptions,
|
|
13
|
+
} from './kubernetes-manifests.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Record<string, Record<string, unknown>>} environments
|
|
17
|
+
* @returns {Set<string>}
|
|
18
|
+
*/
|
|
19
|
+
export function getDeployTypes(environments) {
|
|
20
|
+
return new Set(
|
|
21
|
+
Object.values(environments)
|
|
22
|
+
.map((env) => /** @type {string} */ (env.type))
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
29
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
30
|
+
*/
|
|
31
|
+
export function needsDockerfile(config, environments = config.environments || {}) {
|
|
32
|
+
const deployTypes = getDeployTypes(environments);
|
|
33
|
+
return deployTypes.has('docker') || deployTypes.has('kubernetes');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
38
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
39
|
+
*/
|
|
40
|
+
export function needsKubernetesManifests(
|
|
41
|
+
config,
|
|
42
|
+
environments = config.environments || {}
|
|
43
|
+
) {
|
|
44
|
+
return getDeployTypes(environments).has('kubernetes');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} cwd
|
|
49
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
50
|
+
* @param {{ silent?: boolean }} [options]
|
|
51
|
+
* @returns {Promise<{ generated: boolean, framework?: string }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureDockerfile(cwd, config, options = {}) {
|
|
54
|
+
const dockerfilePath = path.join(cwd, 'Dockerfile');
|
|
55
|
+
if (await fs.pathExists(dockerfilePath)) {
|
|
56
|
+
return { generated: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!needsDockerfile(config)) {
|
|
60
|
+
return { generated: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const settings = resolveDockerSettings(config);
|
|
64
|
+
const content = generateDockerfile(config);
|
|
65
|
+
await fs.writeFile(dockerfilePath, content);
|
|
66
|
+
|
|
67
|
+
if (!options.silent) {
|
|
68
|
+
const label = getDockerfileFrameworkLabel(settings.framework);
|
|
69
|
+
console.log(
|
|
70
|
+
chalk.yellow(
|
|
71
|
+
`No Dockerfile found — generated a starter Dockerfile at ./Dockerfile based on your detected ${label}. Review it before deploying, especially the exposed port and start command.`
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { generated: true, framework: settings.framework };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} cwd
|
|
81
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
82
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
83
|
+
* @param {{ silent?: boolean }} [options]
|
|
84
|
+
* @returns {Promise<{ generated: boolean }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function ensureKubernetesManifests(
|
|
87
|
+
cwd,
|
|
88
|
+
config,
|
|
89
|
+
environments = config.environments || {},
|
|
90
|
+
options = {}
|
|
91
|
+
) {
|
|
92
|
+
if (!needsKubernetesManifests(config, environments)) {
|
|
93
|
+
return { generated: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (await hasKubernetesManifests(cwd)) {
|
|
97
|
+
return { generated: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const manifestOptions = resolveKubernetesManifestOptions(config, environments);
|
|
101
|
+
const { deploymentYaml, serviceYaml } = generateKubernetesManifests(manifestOptions);
|
|
102
|
+
|
|
103
|
+
const k8sDir = path.join(cwd, 'k8s');
|
|
104
|
+
await fs.ensureDir(k8sDir);
|
|
105
|
+
await fs.writeFile(path.join(k8sDir, 'deployment.yaml'), deploymentYaml);
|
|
106
|
+
await fs.writeFile(path.join(k8sDir, 'service.yaml'), serviceYaml);
|
|
107
|
+
|
|
108
|
+
if (!options.silent) {
|
|
109
|
+
console.log(
|
|
110
|
+
chalk.yellow(
|
|
111
|
+
'No Kubernetes manifests found — generated starter manifests at ./k8s/deployment.yaml and ./k8s/service.yaml. Review resource limits, replica count, and any environment-specific settings before deploying.'
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { generated: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} cwd
|
|
121
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
122
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
123
|
+
* @param {{ silent?: boolean }} [options]
|
|
124
|
+
* @returns {Promise<{ dockerfile: boolean, kubernetes: boolean }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function ensureDeployScaffold(
|
|
127
|
+
cwd,
|
|
128
|
+
config,
|
|
129
|
+
environments = config.environments || {},
|
|
130
|
+
options = {}
|
|
131
|
+
) {
|
|
132
|
+
const dockerResult = await ensureDockerfile(cwd, config, options);
|
|
133
|
+
const k8sResult = await ensureKubernetesManifests(cwd, config, environments, options);
|
|
134
|
+
return {
|
|
135
|
+
dockerfile: dockerResult.generated,
|
|
136
|
+
kubernetes: k8sResult.generated,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} srcDir
|
|
142
|
+
* @param {string} destDir
|
|
143
|
+
*/
|
|
144
|
+
export async function copyKubernetesManifestsIfPresent(srcDir, destDir) {
|
|
145
|
+
const k8sSrc = path.join(srcDir, 'k8s');
|
|
146
|
+
if (await fs.pathExists(k8sSrc)) {
|
|
147
|
+
await fs.copy(k8sSrc, path.join(destDir, 'k8s'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let files = [];
|
|
152
|
+
try {
|
|
153
|
+
files = await fs.readdir(srcDir);
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (!/\.ya?ml$/i.test(file)) continue;
|
|
160
|
+
const srcFile = path.join(srcDir, file);
|
|
161
|
+
const content = await fs.readFile(srcFile, 'utf-8');
|
|
162
|
+
if (/^\s*apiVersion:/m.test(content) && /^\s*kind:/m.test(content)) {
|
|
163
|
+
await fs.copy(srcFile, path.join(destDir, file));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Copy deploy-time assets from staging into artifactDir (alongside artifact.zip).
|
|
170
|
+
* @param {string} stagingDir
|
|
171
|
+
* @param {string} artifactDir
|
|
172
|
+
*/
|
|
173
|
+
export async function copyDeployAssetsToArtifactDir(stagingDir, artifactDir) {
|
|
174
|
+
for (const file of ['Dockerfile', 'docker-compose.yml']) {
|
|
175
|
+
const src = path.join(stagingDir, file);
|
|
176
|
+
if (await fs.pathExists(src)) {
|
|
177
|
+
await fs.copy(src, path.join(artifactDir, file));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await copyKubernetesManifestsIfPresent(stagingDir, artifactDir);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default {
|
|
185
|
+
ensureDockerfile,
|
|
186
|
+
ensureKubernetesManifests,
|
|
187
|
+
ensureDeployScaffold,
|
|
188
|
+
needsDockerfile,
|
|
189
|
+
needsKubernetesManifests,
|
|
190
|
+
copyKubernetesManifestsIfPresent,
|
|
191
|
+
copyDeployAssetsToArtifactDir,
|
|
192
|
+
};
|