@hed-hog/cli 0.0.49 ā 0.0.51
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/dist/package.json +1 -1
- package/dist/src/modules/developer/developer.service.d.ts +15 -3
- package/dist/src/modules/developer/developer.service.js +479 -564
- package/dist/src/modules/developer/developer.service.js.map +1 -1
- package/dist/src/templates/deployment/DEPLOYMENT.md.ejs +217 -0
- package/dist/src/templates/deployment/helm.service.chart.yaml.ejs +6 -0
- package/dist/src/templates/deployment/helm.service.deployment.yaml.ejs +20 -0
- package/dist/src/templates/deployment/helm.service.service.yaml.ejs +14 -0
- package/dist/src/templates/deployment/helm.service.values.yaml.ejs +7 -0
- package/dist/src/templates/deployment/k8s.cert-manager-issuer.yaml.ejs +14 -0
- package/dist/src/templates/deployment/k8s.deployment.yaml.ejs +14 -0
- package/dist/src/templates/deployment/k8s.ingress.yaml.ejs +54 -0
- package/dist/src/templates/deployment/k8s.service.yaml.ejs +6 -0
- package/dist/src/templates/deployment/workflow.deploy.yml.ejs +133 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/dist/src/templates/deployment/admin.Dockerfile.ejs +0 -14
- package/dist/src/templates/deployment/api.Dockerfile.ejs +0 -12
|
@@ -23,6 +23,7 @@ const promises_1 = require("fs/promises");
|
|
|
23
23
|
const inquirer_1 = require("inquirer");
|
|
24
24
|
const ora = require("ora");
|
|
25
25
|
const pathModule = require("path");
|
|
26
|
+
const yaml_1 = require("yaml");
|
|
26
27
|
const to_pascal_case_1 = require("../../functions/to-pascal-case");
|
|
27
28
|
const database_service_1 = require("../database/database.service");
|
|
28
29
|
const hedhog_service_1 = require("../hedhog/hedhog.service");
|
|
@@ -160,7 +161,7 @@ let DeveloperService = class DeveloperService {
|
|
|
160
161
|
}
|
|
161
162
|
console.log(chalk.green('\nā All required tools are available!\n'));
|
|
162
163
|
// Start deployment configuration wizard
|
|
163
|
-
const config = await this.runDeploymentWizard();
|
|
164
|
+
const config = await this.runDeploymentWizard(path);
|
|
164
165
|
if (!config) {
|
|
165
166
|
console.log(chalk.yellow('\nDeployment configuration cancelled.\n'));
|
|
166
167
|
return;
|
|
@@ -547,17 +548,307 @@ let DeveloperService = class DeveloperService {
|
|
|
547
548
|
return [];
|
|
548
549
|
}
|
|
549
550
|
}
|
|
550
|
-
|
|
551
|
+
extractContainerPort(ports) {
|
|
552
|
+
for (const portEntry of ports || []) {
|
|
553
|
+
if (typeof portEntry === 'number' && portEntry > 0) {
|
|
554
|
+
return portEntry;
|
|
555
|
+
}
|
|
556
|
+
if (typeof portEntry === 'string') {
|
|
557
|
+
const segment = portEntry.includes(':')
|
|
558
|
+
? portEntry.split(':').pop() || ''
|
|
559
|
+
: portEntry;
|
|
560
|
+
const normalized = segment.split('/')[0];
|
|
561
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
562
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
563
|
+
return parsed;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (portEntry && typeof portEntry === 'object') {
|
|
567
|
+
const target = Number.parseInt(String(portEntry.target ?? ''), 10);
|
|
568
|
+
if (Number.isInteger(target) && target > 0) {
|
|
569
|
+
return target;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
getDefaultAppPort(app) {
|
|
576
|
+
if (app === 'api')
|
|
577
|
+
return 3000;
|
|
578
|
+
if (app === 'admin')
|
|
579
|
+
return 80;
|
|
580
|
+
return 80;
|
|
581
|
+
}
|
|
582
|
+
extractDockerfileExposedPort(dockerfileContent) {
|
|
583
|
+
const exposeRegex = /^\s*EXPOSE\s+([^\r\n#]+)$/gim;
|
|
584
|
+
let match;
|
|
585
|
+
while ((match = exposeRegex.exec(dockerfileContent)) !== null) {
|
|
586
|
+
const declaration = (match[1] || '').trim();
|
|
587
|
+
if (!declaration)
|
|
588
|
+
continue;
|
|
589
|
+
const tokens = declaration.split(/\s+/);
|
|
590
|
+
for (const token of tokens) {
|
|
591
|
+
const normalized = token.split('/')[0];
|
|
592
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
593
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
594
|
+
return parsed;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
async getAppPortsFromDockerfiles(projectPath, apps) {
|
|
601
|
+
const appPorts = {};
|
|
602
|
+
for (const app of apps) {
|
|
603
|
+
const dockerfilePath = pathModule.join(projectPath, 'apps', app, 'Dockerfile');
|
|
604
|
+
if (!(0, fs_1.existsSync)(dockerfilePath)) {
|
|
605
|
+
appPorts[app] = this.getDefaultAppPort(app);
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
const dockerfileContent = await (0, promises_1.readFile)(dockerfilePath, 'utf8');
|
|
610
|
+
appPorts[app] =
|
|
611
|
+
this.extractDockerfileExposedPort(dockerfileContent) ||
|
|
612
|
+
this.getDefaultAppPort(app);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
appPorts[app] = this.getDefaultAppPort(app);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return appPorts;
|
|
619
|
+
}
|
|
620
|
+
async getDockerComposeServices(projectPath) {
|
|
621
|
+
const candidates = ['docker-compose.yaml', 'docker-compose.yml'];
|
|
622
|
+
for (const fileName of candidates) {
|
|
623
|
+
const filePath = pathModule.join(projectPath, fileName);
|
|
624
|
+
if (!(0, fs_1.existsSync)(filePath)) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
const content = await (0, promises_1.readFile)(filePath, 'utf8');
|
|
629
|
+
const parsed = (0, yaml_1.parse)(content);
|
|
630
|
+
const servicesObject = parsed?.services;
|
|
631
|
+
if (!servicesObject || typeof servicesObject !== 'object') {
|
|
632
|
+
return { fileName, services: [] };
|
|
633
|
+
}
|
|
634
|
+
const services = Object.entries(servicesObject)
|
|
635
|
+
.map(([name, serviceConfig]) => {
|
|
636
|
+
const image = typeof serviceConfig?.image === 'string'
|
|
637
|
+
? serviceConfig.image.trim()
|
|
638
|
+
: '';
|
|
639
|
+
if (!image) {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
const containerPort = this.extractContainerPort(Array.isArray(serviceConfig?.ports) ? serviceConfig.ports : []);
|
|
643
|
+
return {
|
|
644
|
+
name,
|
|
645
|
+
image,
|
|
646
|
+
containerPort,
|
|
647
|
+
};
|
|
648
|
+
})
|
|
649
|
+
.filter((service) => service != null);
|
|
650
|
+
return { fileName, services };
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
this.log(chalk.yellow(`Could not parse ${fileName}. Skipping compose services detection.`));
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
async getAvailableApps(projectPath) {
|
|
660
|
+
const appsDir = pathModule.join(projectPath, 'apps');
|
|
661
|
+
const fallbackApps = ['api', 'admin'];
|
|
662
|
+
if (!(0, fs_1.existsSync)(appsDir)) {
|
|
663
|
+
return fallbackApps;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const entries = await (0, promises_1.readdir)(appsDir, { withFileTypes: true });
|
|
667
|
+
const detectedApps = entries
|
|
668
|
+
.filter((entry) => entry.isDirectory())
|
|
669
|
+
.map((entry) => entry.name)
|
|
670
|
+
.filter((name) => /^[a-z0-9][a-z0-9-]*$/i.test(name));
|
|
671
|
+
if (detectedApps.length === 0) {
|
|
672
|
+
return fallbackApps;
|
|
673
|
+
}
|
|
674
|
+
const preferred = ['api', 'admin'];
|
|
675
|
+
return [
|
|
676
|
+
...preferred.filter((app) => detectedApps.includes(app)),
|
|
677
|
+
...detectedApps
|
|
678
|
+
.filter((app) => !preferred.includes(app))
|
|
679
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
680
|
+
];
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return fallbackApps;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async detectAppFramework(projectPath, app) {
|
|
687
|
+
const packageJsonPath = pathModule.join(projectPath, 'apps', app, 'package.json');
|
|
688
|
+
try {
|
|
689
|
+
if (!(0, fs_1.existsSync)(packageJsonPath)) {
|
|
690
|
+
if (app === 'api')
|
|
691
|
+
return 'nestjs';
|
|
692
|
+
if (app === 'admin')
|
|
693
|
+
return 'nextjs';
|
|
694
|
+
return 'node';
|
|
695
|
+
}
|
|
696
|
+
const content = await (0, promises_1.readFile)(packageJsonPath, 'utf8');
|
|
697
|
+
const pkg = JSON.parse(content);
|
|
698
|
+
const deps = {
|
|
699
|
+
...(pkg?.dependencies || {}),
|
|
700
|
+
...(pkg?.devDependencies || {}),
|
|
701
|
+
};
|
|
702
|
+
const scripts = pkg?.scripts || {};
|
|
703
|
+
const scriptText = Object.values(scripts).join(' ').toLowerCase();
|
|
704
|
+
if (deps.next)
|
|
705
|
+
return 'nextjs';
|
|
706
|
+
if (deps['@nestjs/core'] || deps['@nestjs/common'])
|
|
707
|
+
return 'nestjs';
|
|
708
|
+
if (deps['@angular/core'])
|
|
709
|
+
return 'angular';
|
|
710
|
+
if (deps.vite || scriptText.includes(' vite'))
|
|
711
|
+
return 'vitejs';
|
|
712
|
+
if (deps.vue)
|
|
713
|
+
return 'vue';
|
|
714
|
+
if (app === 'api')
|
|
715
|
+
return 'nestjs';
|
|
716
|
+
if (app === 'admin')
|
|
717
|
+
return 'nextjs';
|
|
718
|
+
return 'node';
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
if (app === 'api')
|
|
722
|
+
return 'nestjs';
|
|
723
|
+
if (app === 'admin')
|
|
724
|
+
return 'nextjs';
|
|
725
|
+
return 'node';
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
getInstallCommand() {
|
|
729
|
+
return [
|
|
730
|
+
'if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; ',
|
|
731
|
+
'elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; ',
|
|
732
|
+
'elif [ -f package-lock.json ]; then npm ci; ',
|
|
733
|
+
'else npm install; fi',
|
|
734
|
+
].join('');
|
|
735
|
+
}
|
|
736
|
+
getBuildCommand(app) {
|
|
737
|
+
return [
|
|
738
|
+
`if [ -f pnpm-lock.yaml ]; then pnpm --filter ${app} build; `,
|
|
739
|
+
`elif [ -f yarn.lock ]; then yarn workspace ${app} build; `,
|
|
740
|
+
`else npm run build --workspace=${app}; fi`,
|
|
741
|
+
].join('');
|
|
742
|
+
}
|
|
743
|
+
getStartCommand(app) {
|
|
744
|
+
return [
|
|
745
|
+
`if [ -f pnpm-lock.yaml ]; then pnpm --filter ${app} start; `,
|
|
746
|
+
`elif [ -f yarn.lock ]; then yarn workspace ${app} start; `,
|
|
747
|
+
`else npm run start --workspace=${app}; fi`,
|
|
748
|
+
].join('');
|
|
749
|
+
}
|
|
750
|
+
generateBasicAppDockerfile(app, framework) {
|
|
751
|
+
const installCmd = this.getInstallCommand();
|
|
752
|
+
const buildCmd = this.getBuildCommand(app);
|
|
753
|
+
const startCmd = this.getStartCommand(app);
|
|
754
|
+
if (framework === 'nestjs') {
|
|
755
|
+
return `FROM node:20-alpine AS builder
|
|
756
|
+
WORKDIR /app
|
|
757
|
+
RUN corepack enable
|
|
758
|
+
COPY . .
|
|
759
|
+
RUN ${installCmd}
|
|
760
|
+
RUN ${buildCmd}
|
|
761
|
+
|
|
762
|
+
FROM node:20-alpine
|
|
763
|
+
WORKDIR /app
|
|
764
|
+
RUN corepack enable
|
|
765
|
+
COPY . .
|
|
766
|
+
RUN ${installCmd}
|
|
767
|
+
COPY --from=builder /app/apps/${app}/dist ./apps/${app}/dist
|
|
768
|
+
ENV NODE_ENV=production
|
|
769
|
+
ENV PORT=3000
|
|
770
|
+
EXPOSE 3000
|
|
771
|
+
CMD ["sh", "-c", "${startCmd}"]
|
|
772
|
+
`;
|
|
773
|
+
}
|
|
774
|
+
if (framework === 'nextjs') {
|
|
775
|
+
return `FROM node:20-alpine AS builder
|
|
776
|
+
WORKDIR /app
|
|
777
|
+
RUN corepack enable
|
|
778
|
+
COPY . .
|
|
779
|
+
RUN ${installCmd}
|
|
780
|
+
RUN ${buildCmd}
|
|
781
|
+
|
|
782
|
+
FROM node:20-alpine
|
|
783
|
+
WORKDIR /app
|
|
784
|
+
RUN corepack enable
|
|
785
|
+
COPY . .
|
|
786
|
+
RUN ${installCmd}
|
|
787
|
+
ENV NODE_ENV=production
|
|
788
|
+
ENV PORT=3000
|
|
789
|
+
EXPOSE 3000
|
|
790
|
+
CMD ["sh", "-c", "${startCmd}"]
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
if (framework === 'vitejs' ||
|
|
794
|
+
framework === 'angular' ||
|
|
795
|
+
framework === 'vue') {
|
|
796
|
+
return `FROM node:20-alpine AS builder
|
|
797
|
+
WORKDIR /app
|
|
798
|
+
RUN corepack enable
|
|
799
|
+
COPY . .
|
|
800
|
+
RUN ${installCmd}
|
|
801
|
+
RUN ${buildCmd}
|
|
802
|
+
|
|
803
|
+
FROM node:20-alpine
|
|
804
|
+
WORKDIR /app
|
|
805
|
+
RUN npm install -g serve
|
|
806
|
+
COPY --from=builder /app/apps/${app} ./apps/${app}
|
|
807
|
+
COPY --from=builder /app/dist ./dist
|
|
808
|
+
ENV NODE_ENV=production
|
|
809
|
+
EXPOSE 80
|
|
810
|
+
CMD ["sh", "-c", "DIR=apps/${app}/dist; [ -d dist/${app}/browser ] && DIR=dist/${app}/browser; [ -d dist/${app} ] && DIR=dist/${app}; serve -s $DIR -l 80"]
|
|
811
|
+
`;
|
|
812
|
+
}
|
|
813
|
+
return `FROM node:20-alpine
|
|
814
|
+
WORKDIR /app
|
|
815
|
+
RUN corepack enable
|
|
816
|
+
COPY . .
|
|
817
|
+
RUN ${installCmd}
|
|
818
|
+
RUN ${buildCmd}
|
|
819
|
+
ENV NODE_ENV=production
|
|
820
|
+
EXPOSE 3000
|
|
821
|
+
CMD ["sh", "-c", "${startCmd}"]
|
|
822
|
+
`;
|
|
823
|
+
}
|
|
824
|
+
async ensureAppDockerfiles(projectPath, apps) {
|
|
825
|
+
const createdFiles = [];
|
|
826
|
+
for (const app of apps) {
|
|
827
|
+
const dockerfilePath = pathModule.join(projectPath, 'apps', app, 'Dockerfile');
|
|
828
|
+
if ((0, fs_1.existsSync)(dockerfilePath)) {
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const framework = await this.detectAppFramework(projectPath, app);
|
|
832
|
+
const dockerfile = this.generateBasicAppDockerfile(app, framework);
|
|
833
|
+
await (0, promises_1.writeFile)(dockerfilePath, dockerfile, 'utf8');
|
|
834
|
+
createdFiles.push(pathModule.join('apps', app, 'Dockerfile'));
|
|
835
|
+
this.log(chalk.green(`Created basic Dockerfile for app ${app} (detected: ${framework})`));
|
|
836
|
+
}
|
|
837
|
+
return createdFiles;
|
|
838
|
+
}
|
|
839
|
+
async runDeploymentWizard(path) {
|
|
551
840
|
console.log(chalk.blue.bold('\nš Deployment Configuration Wizard\n'));
|
|
552
841
|
console.log(chalk.gray('This wizard will help you set up CI/CD for your project.\n'));
|
|
553
842
|
// Gather context information
|
|
554
843
|
const spinner = ora('Gathering environment information...').start();
|
|
555
|
-
const gitRepoName = await this.getGitRepoName(
|
|
556
|
-
const currentDir = pathModule.basename(
|
|
844
|
+
const gitRepoName = await this.getGitRepoName(path);
|
|
845
|
+
const currentDir = pathModule.basename(path);
|
|
557
846
|
const defaultAppName = gitRepoName || currentDir;
|
|
558
847
|
const currentContext = await this.getCurrentKubeContext();
|
|
559
848
|
const availableNamespaces = await this.getKubeNamespaces();
|
|
560
849
|
const availableClusters = await this.getKubeClusters();
|
|
850
|
+
const composeData = await this.getDockerComposeServices(path);
|
|
851
|
+
const availableApps = await this.getAvailableApps(path);
|
|
561
852
|
spinner.stop();
|
|
562
853
|
if (currentContext) {
|
|
563
854
|
console.log(chalk.gray(`Current kubectl context: ${chalk.cyan(currentContext)}\n`));
|
|
@@ -565,6 +856,14 @@ let DeveloperService = class DeveloperService {
|
|
|
565
856
|
if (availableNamespaces.length > 0) {
|
|
566
857
|
console.log(chalk.gray(`Available namespaces: ${chalk.cyan(availableNamespaces.join(', '))}\n`));
|
|
567
858
|
}
|
|
859
|
+
if (composeData) {
|
|
860
|
+
if (composeData.services.length > 0) {
|
|
861
|
+
console.log(chalk.gray(`Detected services in ${composeData.fileName}: ${chalk.cyan(composeData.services.map((s) => s.name).join(', '))}\n`));
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
console.log(chalk.gray(`Detected ${composeData.fileName}, but no image-based services were found.\n`));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
568
867
|
const answers = await inquirer_1.default.prompt([
|
|
569
868
|
{
|
|
570
869
|
type: 'list',
|
|
@@ -586,6 +885,23 @@ let DeveloperService = class DeveloperService {
|
|
|
586
885
|
],
|
|
587
886
|
default: 'github-actions',
|
|
588
887
|
},
|
|
888
|
+
{
|
|
889
|
+
type: 'input',
|
|
890
|
+
name: 'deployBranch',
|
|
891
|
+
message: 'Which branch should trigger automatic deployment?',
|
|
892
|
+
default: 'production',
|
|
893
|
+
validate: (input) => {
|
|
894
|
+
const branch = String(input || '').trim();
|
|
895
|
+
if (!branch) {
|
|
896
|
+
return 'Branch name is required';
|
|
897
|
+
}
|
|
898
|
+
if (/\s/.test(branch)) {
|
|
899
|
+
return 'Branch name cannot contain spaces';
|
|
900
|
+
}
|
|
901
|
+
return true;
|
|
902
|
+
},
|
|
903
|
+
when: (answers) => answers.cicd === 'github-actions',
|
|
904
|
+
},
|
|
589
905
|
{
|
|
590
906
|
type: 'list',
|
|
591
907
|
name: 'clusterSelection',
|
|
@@ -682,10 +998,27 @@ let DeveloperService = class DeveloperService {
|
|
|
682
998
|
type: 'checkbox',
|
|
683
999
|
name: 'apps',
|
|
684
1000
|
message: 'Select which apps to deploy:',
|
|
685
|
-
choices:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1001
|
+
choices: availableApps.map((app) => ({
|
|
1002
|
+
name: app === 'api'
|
|
1003
|
+
? 'API (Backend)'
|
|
1004
|
+
: app === 'admin'
|
|
1005
|
+
? 'Admin (Frontend)'
|
|
1006
|
+
: `${app} (Detected app)`,
|
|
1007
|
+
value: app,
|
|
1008
|
+
checked: app === 'api' || app === 'admin',
|
|
1009
|
+
})),
|
|
1010
|
+
validate: (selected) => (selected || []).length > 0 || 'Select at least one app',
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
type: 'checkbox',
|
|
1014
|
+
name: 'selectedInfraServices',
|
|
1015
|
+
message: 'Select additional docker-compose services to provision via Helm charts:',
|
|
1016
|
+
choices: (composeData?.services || []).map((service) => ({
|
|
1017
|
+
name: `${service.name} (${service.image})`,
|
|
1018
|
+
value: service.name,
|
|
1019
|
+
checked: true,
|
|
1020
|
+
})),
|
|
1021
|
+
when: () => (composeData?.services || []).length > 0,
|
|
689
1022
|
},
|
|
690
1023
|
{
|
|
691
1024
|
type: 'confirm',
|
|
@@ -728,9 +1061,41 @@ let DeveloperService = class DeveloperService {
|
|
|
728
1061
|
}
|
|
729
1062
|
// Store if namespace needs to be created
|
|
730
1063
|
const createNamespace = answers.namespaceSelection === 'new';
|
|
1064
|
+
const parseReplicaCount = (value) => {
|
|
1065
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
1066
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 2;
|
|
1067
|
+
};
|
|
1068
|
+
const replicaAnswers = await inquirer_1.default.prompt((answers.apps || []).map((app) => ({
|
|
1069
|
+
type: 'input',
|
|
1070
|
+
name: `replicas_${app}`,
|
|
1071
|
+
message: app === 'api'
|
|
1072
|
+
? 'How many replicas should API (Backend) have?'
|
|
1073
|
+
: app === 'admin'
|
|
1074
|
+
? 'How many replicas should Admin (Frontend) have?'
|
|
1075
|
+
: `How many replicas should ${app} have?`,
|
|
1076
|
+
default: 2,
|
|
1077
|
+
validate: (input) => {
|
|
1078
|
+
const value = Number.parseInt(String(input), 10);
|
|
1079
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
1080
|
+
return 'Please enter a whole number greater than or equal to 1';
|
|
1081
|
+
}
|
|
1082
|
+
return true;
|
|
1083
|
+
},
|
|
1084
|
+
})));
|
|
1085
|
+
const appReplicas = {};
|
|
1086
|
+
(answers.apps || []).forEach((app) => {
|
|
1087
|
+
appReplicas[app] = parseReplicaCount(replicaAnswers[`replicas_${app}`]);
|
|
1088
|
+
});
|
|
1089
|
+
const selectedInfraServices = new Set((answers.selectedInfraServices || []));
|
|
1090
|
+
const infraServices = (composeData?.services || []).filter((service) => selectedInfraServices.has(service.name));
|
|
1091
|
+
const appPorts = await this.getAppPortsFromDockerfiles(path, answers.apps || []);
|
|
731
1092
|
return {
|
|
732
1093
|
...answers,
|
|
733
1094
|
createNamespace,
|
|
1095
|
+
appReplicas,
|
|
1096
|
+
appPorts,
|
|
1097
|
+
infraServices,
|
|
1098
|
+
composeFileName: composeData?.fileName,
|
|
734
1099
|
};
|
|
735
1100
|
}
|
|
736
1101
|
async generateDeploymentFiles(path, config) {
|
|
@@ -740,12 +1105,14 @@ let DeveloperService = class DeveloperService {
|
|
|
740
1105
|
// Create k8s directory
|
|
741
1106
|
const k8sDir = pathModule.join(path, 'k8s');
|
|
742
1107
|
await (0, promises_1.mkdir)(k8sDir, { recursive: true });
|
|
743
|
-
// Generate Dockerfiles and .dockerignore for each app
|
|
744
|
-
for (const app of config.apps) {
|
|
745
|
-
await this.generateDockerfile(path, app, config);
|
|
746
|
-
}
|
|
747
1108
|
// Generate .dockerignore in root
|
|
748
1109
|
await this.generateDockerignore(path);
|
|
1110
|
+
// Create basic app Dockerfiles when missing
|
|
1111
|
+
const createdDockerfiles = await this.ensureAppDockerfiles(path, config.apps || []);
|
|
1112
|
+
if (createdDockerfiles.length > 0) {
|
|
1113
|
+
config.createdDockerfiles = createdDockerfiles;
|
|
1114
|
+
config.appPorts = await this.getAppPortsFromDockerfiles(path, config.apps || []);
|
|
1115
|
+
}
|
|
749
1116
|
// Generate GitHub Actions workflow
|
|
750
1117
|
await this.generateGitHubActionsWorkflow(workflowsDir, config);
|
|
751
1118
|
// Generate Kubernetes manifests for each app
|
|
@@ -760,93 +1127,62 @@ let DeveloperService = class DeveloperService {
|
|
|
760
1127
|
if (config.setupSSL) {
|
|
761
1128
|
await this.generateCertManagerIssuer(k8sDir, config);
|
|
762
1129
|
}
|
|
763
|
-
// Generate Helm
|
|
764
|
-
await this.
|
|
1130
|
+
// Generate optional Helm charts for detected docker-compose services
|
|
1131
|
+
await this.generateComposeServiceHelmCharts(path, config);
|
|
765
1132
|
// Generate README with deployment instructions
|
|
766
1133
|
await this.generateDeploymentReadme(path, config);
|
|
767
1134
|
}
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1135
|
+
getDefaultServicePort(serviceName) {
|
|
1136
|
+
const name = serviceName.toLowerCase();
|
|
1137
|
+
if (name.includes('postgres'))
|
|
1138
|
+
return 5432;
|
|
1139
|
+
if (name.includes('mysql'))
|
|
1140
|
+
return 3306;
|
|
1141
|
+
if (name.includes('redis'))
|
|
1142
|
+
return 6379;
|
|
1143
|
+
if (name.includes('rabbitmq'))
|
|
1144
|
+
return 5672;
|
|
1145
|
+
return 80;
|
|
1146
|
+
}
|
|
1147
|
+
normalizeYamlContent(content, fileLabel) {
|
|
776
1148
|
try {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
await (0, promises_1.writeFile)(dockerfilePath, renderedContent, 'utf8');
|
|
780
|
-
this.log(chalk.green(`Created Dockerfile for ${app}`));
|
|
1149
|
+
const parsed = (0, yaml_1.parse)(content);
|
|
1150
|
+
return (0, yaml_1.stringify)(parsed, { lineWidth: 0 });
|
|
781
1151
|
}
|
|
782
|
-
catch
|
|
783
|
-
this.log(chalk.yellow(`Could not
|
|
784
|
-
|
|
785
|
-
const basicDockerfile = this.generateBasicDockerfile(app);
|
|
786
|
-
await (0, promises_1.writeFile)(dockerfilePath, basicDockerfile, 'utf8');
|
|
787
|
-
this.log(chalk.green(`Created basic Dockerfile for ${app}`));
|
|
1152
|
+
catch {
|
|
1153
|
+
this.log(chalk.yellow(`Could not normalize YAML for ${fileLabel}; using rendered content as-is.`));
|
|
1154
|
+
return content;
|
|
788
1155
|
}
|
|
789
1156
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
WORKDIR /app
|
|
795
|
-
COPY package.json pnpm-lock.yaml ./
|
|
796
|
-
COPY apps/api/package.json ./apps/api/
|
|
797
|
-
RUN npm install -g pnpm
|
|
798
|
-
RUN pnpm install --frozen-lockfile
|
|
799
|
-
COPY . .
|
|
800
|
-
RUN pnpm --filter api build
|
|
801
|
-
|
|
802
|
-
FROM node:18-alpine
|
|
803
|
-
WORKDIR /app
|
|
804
|
-
COPY package.json pnpm-lock.yaml ./
|
|
805
|
-
COPY apps/api/package.json ./apps/api/
|
|
806
|
-
RUN npm install -g pnpm
|
|
807
|
-
RUN pnpm install --frozen-lockfile --prod
|
|
808
|
-
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
|
809
|
-
ENV NODE_ENV=production
|
|
810
|
-
ENV PORT=3000
|
|
811
|
-
EXPOSE 3000
|
|
812
|
-
CMD ["node", "apps/api/dist/main.js"]
|
|
813
|
-
`;
|
|
1157
|
+
async generateComposeServiceHelmCharts(path, config) {
|
|
1158
|
+
const infraServices = (config.infraServices || []);
|
|
1159
|
+
if (infraServices.length === 0) {
|
|
1160
|
+
return;
|
|
814
1161
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
`;
|
|
1162
|
+
const servicesRoot = pathModule.join(path, 'helm', 'services');
|
|
1163
|
+
await (0, promises_1.mkdir)(servicesRoot, { recursive: true });
|
|
1164
|
+
for (const service of infraServices) {
|
|
1165
|
+
const chartDir = pathModule.join(servicesRoot, service.name);
|
|
1166
|
+
const templatesDir = pathModule.join(chartDir, 'templates');
|
|
1167
|
+
await (0, promises_1.mkdir)(templatesDir, { recursive: true });
|
|
1168
|
+
const servicePort = service.containerPort || this.getDefaultServicePort(service.name);
|
|
1169
|
+
const chartTemplatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'helm.service.chart.yaml.ejs');
|
|
1170
|
+
const valuesTemplatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'helm.service.values.yaml.ejs');
|
|
1171
|
+
const deploymentTemplatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'helm.service.deployment.yaml.ejs');
|
|
1172
|
+
const serviceTemplatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'helm.service.service.yaml.ejs');
|
|
1173
|
+
const chartYaml = await (0, ejs_1.render)(await (0, promises_1.readFile)(chartTemplatePath, 'utf8'), {
|
|
1174
|
+
config,
|
|
1175
|
+
service,
|
|
1176
|
+
servicePort,
|
|
1177
|
+
});
|
|
1178
|
+
const valuesYaml = await (0, ejs_1.render)(await (0, promises_1.readFile)(valuesTemplatePath, 'utf8'), { config, service, servicePort });
|
|
1179
|
+
const deploymentTemplate = await (0, ejs_1.render)(await (0, promises_1.readFile)(deploymentTemplatePath, 'utf8'), { config, service, servicePort });
|
|
1180
|
+
const serviceTemplate = await (0, ejs_1.render)(await (0, promises_1.readFile)(serviceTemplatePath, 'utf8'), { config, service, servicePort });
|
|
1181
|
+
await (0, promises_1.writeFile)(pathModule.join(chartDir, 'Chart.yaml'), this.normalizeYamlContent(chartYaml, `helm/services/${service.name}/Chart.yaml`), 'utf8');
|
|
1182
|
+
await (0, promises_1.writeFile)(pathModule.join(chartDir, 'values.yaml'), this.normalizeYamlContent(valuesYaml, `helm/services/${service.name}/values.yaml`), 'utf8');
|
|
1183
|
+
await (0, promises_1.writeFile)(pathModule.join(templatesDir, 'deployment.yaml'), deploymentTemplate, 'utf8');
|
|
1184
|
+
await (0, promises_1.writeFile)(pathModule.join(templatesDir, 'service.yaml'), serviceTemplate, 'utf8');
|
|
839
1185
|
}
|
|
840
|
-
return `# Dockerfile for ${app}
|
|
841
|
-
FROM node:18-alpine
|
|
842
|
-
WORKDIR /app
|
|
843
|
-
COPY . .
|
|
844
|
-
RUN npm install -g pnpm
|
|
845
|
-
RUN pnpm install --frozen-lockfile
|
|
846
|
-
RUN pnpm build
|
|
847
|
-
EXPOSE 3000
|
|
848
|
-
CMD ["pnpm", "start"]
|
|
849
|
-
`;
|
|
850
1186
|
}
|
|
851
1187
|
async generateDockerignore(path) {
|
|
852
1188
|
const dockerignorePath = pathModule.join(path, '.dockerignore');
|
|
@@ -888,59 +1224,9 @@ temp
|
|
|
888
1224
|
}
|
|
889
1225
|
}
|
|
890
1226
|
async generateGitHubActionsWorkflow(dir, config) {
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
push:
|
|
895
|
-
branches:
|
|
896
|
-
- main
|
|
897
|
-
- production
|
|
898
|
-
workflow_dispatch:
|
|
899
|
-
|
|
900
|
-
env:
|
|
901
|
-
REGISTRY: ${config.containerRegistry}
|
|
902
|
-
CLUSTER_NAME: ${config.clusterName}
|
|
903
|
-
NAMESPACE: ${config.namespace}
|
|
904
|
-
|
|
905
|
-
jobs:
|
|
906
|
-
${config.apps
|
|
907
|
-
.map((app) => ` deploy-${app}:
|
|
908
|
-
name: Deploy ${app.toUpperCase()}
|
|
909
|
-
runs-on: ubuntu-latest
|
|
910
|
-
|
|
911
|
-
steps:
|
|
912
|
-
- name: Checkout code
|
|
913
|
-
uses: actions/checkout@v4
|
|
914
|
-
|
|
915
|
-
- name: Install doctl
|
|
916
|
-
uses: digitalocean/action-doctl@v2
|
|
917
|
-
with:
|
|
918
|
-
token: \${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
|
919
|
-
|
|
920
|
-
- name: Log in to Container Registry
|
|
921
|
-
run: doctl registry login
|
|
922
|
-
|
|
923
|
-
- name: Build and push Docker image
|
|
924
|
-
run: |
|
|
925
|
-
docker build -t \${{ env.REGISTRY }}/${config.appName}-${app}:\${{ github.sha }} \\
|
|
926
|
-
-f apps/${app}/Dockerfile .
|
|
927
|
-
docker push \${{ env.REGISTRY }}/${config.appName}-${app}:\${{ github.sha }}
|
|
928
|
-
docker tag \${{ env.REGISTRY }}/${config.appName}-${app}:\${{ github.sha }} \\
|
|
929
|
-
\${{ env.REGISTRY }}/${config.appName}-${app}:latest
|
|
930
|
-
docker push \${{ env.REGISTRY }}/${config.appName}-${app}:latest
|
|
931
|
-
|
|
932
|
-
- name: Save DigitalOcean kubeconfig
|
|
933
|
-
run: doctl kubernetes cluster kubeconfig save \${{ env.CLUSTER_NAME }}
|
|
934
|
-
|
|
935
|
-
- name: Deploy to Kubernetes
|
|
936
|
-
run: |
|
|
937
|
-
kubectl set image deployment/${config.appName}-${app} \\
|
|
938
|
-
${config.appName}-${app}=\${{ env.REGISTRY }}/${config.appName}-${app}:\${{ github.sha }} \\
|
|
939
|
-
-n \${{ env.NAMESPACE }}
|
|
940
|
-
kubectl rollout status deployment/${config.appName}-${app} -n \${{ env.NAMESPACE }}
|
|
941
|
-
`)
|
|
942
|
-
.join('\n')}
|
|
943
|
-
`;
|
|
1227
|
+
const templatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'workflow.deploy.yml.ejs');
|
|
1228
|
+
const templateContent = await (0, promises_1.readFile)(templatePath, 'utf8');
|
|
1229
|
+
const workflowContent = await (0, ejs_1.render)(templateContent, { config });
|
|
944
1230
|
const workflowPath = pathModule.join(dir, 'deploy.yml');
|
|
945
1231
|
await (0, promises_1.writeFile)(workflowPath, workflowContent, 'utf8');
|
|
946
1232
|
this.log(chalk.green(`Created GitHub Actions workflow: ${workflowPath}`));
|
|
@@ -948,430 +1234,34 @@ ${config.apps
|
|
|
948
1234
|
async generateKubernetesManifests(dir, app, config) {
|
|
949
1235
|
const appDir = pathModule.join(dir, app);
|
|
950
1236
|
await (0, promises_1.mkdir)(appDir, { recursive: true });
|
|
951
|
-
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
spec:
|
|
960
|
-
replicas: 2
|
|
961
|
-
selector:
|
|
962
|
-
matchLabels:
|
|
963
|
-
app: ${config.appName}-${app}
|
|
964
|
-
template:
|
|
965
|
-
metadata:
|
|
966
|
-
labels:
|
|
967
|
-
app: ${config.appName}-${app}
|
|
968
|
-
spec:
|
|
969
|
-
containers:
|
|
970
|
-
- name: ${config.appName}-${app}
|
|
971
|
-
image: ${config.containerRegistry}/${config.appName}-${app}:latest
|
|
972
|
-
ports:
|
|
973
|
-
- containerPort: ${app === 'api' ? '3000' : '80'}
|
|
974
|
-
env:
|
|
975
|
-
- name: NODE_ENV
|
|
976
|
-
value: "production"
|
|
977
|
-
resources:
|
|
978
|
-
requests:
|
|
979
|
-
memory: "256Mi"
|
|
980
|
-
cpu: "100m"
|
|
981
|
-
limits:
|
|
982
|
-
memory: "512Mi"
|
|
983
|
-
cpu: "500m"
|
|
984
|
-
livenessProbe:
|
|
985
|
-
httpGet:
|
|
986
|
-
path: ${app === 'api' ? '/health' : '/'}
|
|
987
|
-
port: ${app === 'api' ? '3000' : '80'}
|
|
988
|
-
initialDelaySeconds: 30
|
|
989
|
-
periodSeconds: 10
|
|
990
|
-
readinessProbe:
|
|
991
|
-
httpGet:
|
|
992
|
-
path: ${app === 'api' ? '/health' : '/'}
|
|
993
|
-
port: ${app === 'api' ? '3000' : '80'}
|
|
994
|
-
initialDelaySeconds: 10
|
|
995
|
-
periodSeconds: 5
|
|
996
|
-
`;
|
|
997
|
-
await (0, promises_1.writeFile)(pathModule.join(appDir, 'deployment.yaml'), deploymentContent, 'utf8');
|
|
998
|
-
// Generate Service
|
|
999
|
-
const serviceContent = `apiVersion: v1
|
|
1000
|
-
kind: Service
|
|
1001
|
-
metadata:
|
|
1002
|
-
name: ${config.appName}-${app}
|
|
1003
|
-
namespace: ${config.namespace}
|
|
1004
|
-
labels:
|
|
1005
|
-
app: ${config.appName}-${app}
|
|
1006
|
-
spec:
|
|
1007
|
-
type: ClusterIP
|
|
1008
|
-
ports:
|
|
1009
|
-
- port: ${app === 'api' ? '3000' : '80'}
|
|
1010
|
-
targetPort: ${app === 'api' ? '3000' : '80'}
|
|
1011
|
-
protocol: TCP
|
|
1012
|
-
name: http
|
|
1013
|
-
selector:
|
|
1014
|
-
app: ${config.appName}-${app}
|
|
1015
|
-
`;
|
|
1016
|
-
await (0, promises_1.writeFile)(pathModule.join(appDir, 'service.yaml'), serviceContent, 'utf8');
|
|
1237
|
+
const deploymentTemplatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'k8s.deployment.yaml.ejs');
|
|
1238
|
+
const deploymentTemplate = await (0, promises_1.readFile)(deploymentTemplatePath, 'utf8');
|
|
1239
|
+
const deploymentContent = await (0, ejs_1.render)(deploymentTemplate, { config, app });
|
|
1240
|
+
await (0, promises_1.writeFile)(pathModule.join(appDir, 'deployment.yaml'), this.normalizeYamlContent(deploymentContent, `k8s/${app}/deployment.yaml`), 'utf8');
|
|
1241
|
+
const serviceTemplatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'k8s.service.yaml.ejs');
|
|
1242
|
+
const serviceTemplate = await (0, promises_1.readFile)(serviceTemplatePath, 'utf8');
|
|
1243
|
+
const serviceContent = await (0, ejs_1.render)(serviceTemplate, { config, app });
|
|
1244
|
+
await (0, promises_1.writeFile)(pathModule.join(appDir, 'service.yaml'), this.normalizeYamlContent(serviceContent, `k8s/${app}/service.yaml`), 'utf8');
|
|
1017
1245
|
this.log(chalk.green(`Created Kubernetes manifests for ${app}`));
|
|
1018
1246
|
}
|
|
1019
1247
|
async generateIngressManifest(dir, config) {
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
namespace: ${config.namespace}
|
|
1025
|
-
annotations:
|
|
1026
|
-
kubernetes.io/ingress.class: "nginx"
|
|
1027
|
-
${config.setupSSL ? ` cert-manager.io/cluster-issuer: "letsencrypt-prod"` : ''}
|
|
1028
|
-
spec:
|
|
1029
|
-
${config.setupSSL
|
|
1030
|
-
? ` tls:
|
|
1031
|
-
- hosts:
|
|
1032
|
-
- ${config.domain}
|
|
1033
|
-
${config.apps.includes('api') ? ` - api.${config.domain}` : ''}
|
|
1034
|
-
secretName: ${config.appName}-tls
|
|
1035
|
-
`
|
|
1036
|
-
: ''} rules:
|
|
1037
|
-
${config.apps.includes('admin')
|
|
1038
|
-
? ` - host: ${config.domain}
|
|
1039
|
-
http:
|
|
1040
|
-
paths:
|
|
1041
|
-
- path: /
|
|
1042
|
-
pathType: Prefix
|
|
1043
|
-
backend:
|
|
1044
|
-
service:
|
|
1045
|
-
name: ${config.appName}-admin
|
|
1046
|
-
port:
|
|
1047
|
-
number: 80
|
|
1048
|
-
`
|
|
1049
|
-
: ''}${config.apps.includes('api')
|
|
1050
|
-
? ` - host: api.${config.domain}
|
|
1051
|
-
http:
|
|
1052
|
-
paths:
|
|
1053
|
-
- path: /
|
|
1054
|
-
pathType: Prefix
|
|
1055
|
-
backend:
|
|
1056
|
-
service:
|
|
1057
|
-
name: ${config.appName}-api
|
|
1058
|
-
port:
|
|
1059
|
-
number: 3000
|
|
1060
|
-
`
|
|
1061
|
-
: ''}`;
|
|
1062
|
-
await (0, promises_1.writeFile)(pathModule.join(dir, 'ingress.yaml'), ingressContent, 'utf8');
|
|
1248
|
+
const templatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'k8s.ingress.yaml.ejs');
|
|
1249
|
+
const templateContent = await (0, promises_1.readFile)(templatePath, 'utf8');
|
|
1250
|
+
const ingressContent = await (0, ejs_1.render)(templateContent, { config });
|
|
1251
|
+
await (0, promises_1.writeFile)(pathModule.join(dir, 'ingress.yaml'), this.normalizeYamlContent(ingressContent, 'k8s/ingress.yaml'), 'utf8');
|
|
1063
1252
|
this.log(chalk.green('Created Ingress manifest'));
|
|
1064
1253
|
}
|
|
1065
1254
|
async generateCertManagerIssuer(dir, config) {
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
spec:
|
|
1071
|
-
acme:
|
|
1072
|
-
server: https://acme-v02.api.letsencrypt.org/directory
|
|
1073
|
-
email: ${config.email}
|
|
1074
|
-
privateKeySecretRef:
|
|
1075
|
-
name: letsencrypt-prod
|
|
1076
|
-
solvers:
|
|
1077
|
-
- http01:
|
|
1078
|
-
ingress:
|
|
1079
|
-
class: nginx
|
|
1080
|
-
`;
|
|
1081
|
-
await (0, promises_1.writeFile)(pathModule.join(dir, 'cert-manager-issuer.yaml'), issuerContent, 'utf8');
|
|
1255
|
+
const templatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'k8s.cert-manager-issuer.yaml.ejs');
|
|
1256
|
+
const templateContent = await (0, promises_1.readFile)(templatePath, 'utf8');
|
|
1257
|
+
const issuerContent = await (0, ejs_1.render)(templateContent, { config });
|
|
1258
|
+
await (0, promises_1.writeFile)(pathModule.join(dir, 'cert-manager-issuer.yaml'), this.normalizeYamlContent(issuerContent, 'k8s/cert-manager-issuer.yaml'), 'utf8');
|
|
1082
1259
|
this.log(chalk.green('Created cert-manager ClusterIssuer'));
|
|
1083
1260
|
}
|
|
1084
|
-
async generateHelmChart(path, config) {
|
|
1085
|
-
const helmDir = pathModule.join(path, 'helm', config.appName);
|
|
1086
|
-
await (0, promises_1.mkdir)(helmDir, { recursive: true });
|
|
1087
|
-
// Generate Chart.yaml
|
|
1088
|
-
const chartContent = `apiVersion: v2
|
|
1089
|
-
name: ${config.appName}
|
|
1090
|
-
description: Helm chart for ${config.appName}
|
|
1091
|
-
type: application
|
|
1092
|
-
version: 1.0.0
|
|
1093
|
-
appVersion: "1.0.0"
|
|
1094
|
-
`;
|
|
1095
|
-
await (0, promises_1.writeFile)(pathModule.join(helmDir, 'Chart.yaml'), chartContent, 'utf8');
|
|
1096
|
-
// Generate values.yaml
|
|
1097
|
-
const valuesContent = `# Default values for ${config.appName}
|
|
1098
|
-
namespace: ${config.namespace}
|
|
1099
|
-
|
|
1100
|
-
registry: ${config.containerRegistry}
|
|
1101
|
-
|
|
1102
|
-
apps:
|
|
1103
|
-
${config.apps
|
|
1104
|
-
.map((app) => ` ${app}:
|
|
1105
|
-
enabled: true
|
|
1106
|
-
replicas: 2
|
|
1107
|
-
image:
|
|
1108
|
-
repository: \${{ .Values.registry }}/${config.appName}-${app}
|
|
1109
|
-
tag: latest
|
|
1110
|
-
pullPolicy: Always
|
|
1111
|
-
service:
|
|
1112
|
-
type: ClusterIP
|
|
1113
|
-
port: ${app === 'api' ? '3000' : '80'}
|
|
1114
|
-
resources:
|
|
1115
|
-
requests:
|
|
1116
|
-
memory: "256Mi"
|
|
1117
|
-
cpu: "100m"
|
|
1118
|
-
limits:
|
|
1119
|
-
memory: "512Mi"
|
|
1120
|
-
cpu: "500m"
|
|
1121
|
-
`)
|
|
1122
|
-
.join('')}
|
|
1123
|
-
${config.setupIngress
|
|
1124
|
-
? `ingress:
|
|
1125
|
-
enabled: true
|
|
1126
|
-
className: nginx
|
|
1127
|
-
annotations:
|
|
1128
|
-
${config.setupSSL ? ` cert-manager.io/cluster-issuer: letsencrypt-prod` : ''}
|
|
1129
|
-
hosts:
|
|
1130
|
-
${config.apps.includes('admin')
|
|
1131
|
-
? ` - host: ${config.domain}
|
|
1132
|
-
paths:
|
|
1133
|
-
- path: /
|
|
1134
|
-
pathType: Prefix
|
|
1135
|
-
backend:
|
|
1136
|
-
service:
|
|
1137
|
-
name: ${config.appName}-admin
|
|
1138
|
-
port:
|
|
1139
|
-
number: 80
|
|
1140
|
-
`
|
|
1141
|
-
: ''}${config.apps.includes('api')
|
|
1142
|
-
? ` - host: api.${config.domain}
|
|
1143
|
-
paths:
|
|
1144
|
-
- path: /
|
|
1145
|
-
pathType: Prefix
|
|
1146
|
-
backend:
|
|
1147
|
-
service:
|
|
1148
|
-
name: ${config.appName}-api
|
|
1149
|
-
port:
|
|
1150
|
-
number: 3000
|
|
1151
|
-
`
|
|
1152
|
-
: ''}${config.setupSSL
|
|
1153
|
-
? ` tls:
|
|
1154
|
-
- secretName: ${config.appName}-tls
|
|
1155
|
-
hosts:
|
|
1156
|
-
- ${config.domain}
|
|
1157
|
-
${config.apps.includes('api') ? ` - api.${config.domain}` : ''}`
|
|
1158
|
-
: ''}`
|
|
1159
|
-
: ''}
|
|
1160
|
-
`;
|
|
1161
|
-
await (0, promises_1.writeFile)(pathModule.join(helmDir, 'values.yaml'), valuesContent, 'utf8');
|
|
1162
|
-
this.log(chalk.green('Created Helm chart'));
|
|
1163
|
-
}
|
|
1164
1261
|
async generateDeploymentReadme(path, config) {
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
## Prerequisites
|
|
1170
|
-
|
|
1171
|
-
Make sure you have the following tools installed:
|
|
1172
|
-
|
|
1173
|
-
- \`kubectl\` - Kubernetes CLI
|
|
1174
|
-
- \`doctl\` - Digital Ocean CLI
|
|
1175
|
-
- \`gh\` - GitHub CLI
|
|
1176
|
-
- \`helm\` - Kubernetes package manager
|
|
1177
|
-
|
|
1178
|
-
## Initial Setup
|
|
1179
|
-
|
|
1180
|
-
### 1. Configure Digital Ocean
|
|
1181
|
-
|
|
1182
|
-
\`\`\`bash
|
|
1183
|
-
# Authenticate with Digital Ocean
|
|
1184
|
-
doctl auth init
|
|
1185
|
-
|
|
1186
|
-
# Get your cluster kubeconfig
|
|
1187
|
-
doctl kubernetes cluster kubeconfig save ${config.clusterName}
|
|
1188
|
-
\`\`\`
|
|
1189
|
-
|
|
1190
|
-
${config.createNamespace
|
|
1191
|
-
? `### 2. Create Kubernetes Namespace
|
|
1192
|
-
|
|
1193
|
-
\`\`\`bash
|
|
1194
|
-
kubectl create namespace ${config.namespace}
|
|
1195
|
-
\`\`\`
|
|
1196
|
-
|
|
1197
|
-
`
|
|
1198
|
-
: `### 2. Verify Namespace
|
|
1199
|
-
|
|
1200
|
-
\`\`\`bash
|
|
1201
|
-
# Verify the namespace exists
|
|
1202
|
-
kubectl get namespace ${config.namespace}
|
|
1203
|
-
\`\`\`
|
|
1204
|
-
|
|
1205
|
-
`}### 3. Configure GitHub Secrets
|
|
1206
|
-
|
|
1207
|
-
Add the following secrets to your GitHub repository:
|
|
1208
|
-
|
|
1209
|
-
1. \`DIGITALOCEAN_ACCESS_TOKEN\` - Your Digital Ocean API token
|
|
1210
|
-
|
|
1211
|
-
\`\`\`bash
|
|
1212
|
-
# Get your DO token and add it to GitHub
|
|
1213
|
-
gh secret set DIGITALOCEAN_ACCESS_TOKEN
|
|
1214
|
-
\`\`\`
|
|
1215
|
-
|
|
1216
|
-
${config.setupSSL
|
|
1217
|
-
? `### 4. Install cert-manager
|
|
1218
|
-
|
|
1219
|
-
\`\`\`bash
|
|
1220
|
-
# Install cert-manager for SSL certificates
|
|
1221
|
-
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
|
|
1222
|
-
|
|
1223
|
-
# Wait for cert-manager to be ready
|
|
1224
|
-
kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s
|
|
1225
|
-
|
|
1226
|
-
# Apply the ClusterIssuer
|
|
1227
|
-
kubectl apply -f k8s/cert-manager-issuer.yaml
|
|
1228
|
-
\`\`\`
|
|
1229
|
-
`
|
|
1230
|
-
: ''}${config.setupIngress
|
|
1231
|
-
? `### ${config.setupSSL ? '5' : '4'}. Install NGINX Ingress Controller
|
|
1232
|
-
|
|
1233
|
-
\`\`\`bash
|
|
1234
|
-
# Install NGINX Ingress Controller
|
|
1235
|
-
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
|
|
1236
|
-
helm repo update
|
|
1237
|
-
|
|
1238
|
-
helm install nginx-ingress ingress-nginx/ingress-nginx \\
|
|
1239
|
-
--namespace ingress-nginx \\
|
|
1240
|
-
--create-namespace \\
|
|
1241
|
-
--set controller.publishService.enabled=true
|
|
1242
|
-
|
|
1243
|
-
# Wait for the load balancer IP
|
|
1244
|
-
kubectl get service nginx-ingress-ingress-nginx-controller -n ingress-nginx --watch
|
|
1245
|
-
\`\`\`
|
|
1246
|
-
|
|
1247
|
-
**Important:** After the LoadBalancer gets an external IP, configure your DNS:
|
|
1248
|
-
|
|
1249
|
-
${config.domain
|
|
1250
|
-
? `- Point \`${config.domain}\` to the LoadBalancer IP
|
|
1251
|
-
${config.apps.includes('api')
|
|
1252
|
-
? `- Point \`api.${config.domain}\` to the LoadBalancer IP
|
|
1253
|
-
`
|
|
1254
|
-
: ''}`
|
|
1255
|
-
: ''}
|
|
1256
|
-
`
|
|
1257
|
-
: ''}
|
|
1258
|
-
## Deployment
|
|
1259
|
-
|
|
1260
|
-
### Using GitHub Actions (Automatic)
|
|
1261
|
-
|
|
1262
|
-
Push to the \`main\` or \`production\` branch:
|
|
1263
|
-
|
|
1264
|
-
\`\`\`bash
|
|
1265
|
-
git add .
|
|
1266
|
-
git commit -m "Deploy to production"
|
|
1267
|
-
git push origin main
|
|
1268
|
-
\`\`\`
|
|
1269
|
-
|
|
1270
|
-
The GitHub Actions workflow will automatically:
|
|
1271
|
-
1. Build Docker images
|
|
1272
|
-
2. Push to container registry
|
|
1273
|
-
3. Deploy to Kubernetes cluster
|
|
1274
|
-
|
|
1275
|
-
### Manual Deployment
|
|
1276
|
-
|
|
1277
|
-
#### Option 1: Using kubectl
|
|
1278
|
-
|
|
1279
|
-
\`\`\`bash
|
|
1280
|
-
# Apply all manifests
|
|
1281
|
-
${config.apps.map((app) => `kubectl apply -f k8s/${app}/`).join('\n')}
|
|
1282
|
-
${config.setupIngress ? `kubectl apply -f k8s/ingress.yaml` : ''}
|
|
1283
|
-
\`\`\`
|
|
1284
|
-
|
|
1285
|
-
#### Option 2: Using Helm
|
|
1286
|
-
|
|
1287
|
-
\`\`\`bash
|
|
1288
|
-
# Install or upgrade the release
|
|
1289
|
-
helm upgrade --install ${config.appName} ./helm/${config.appName} \\
|
|
1290
|
-
--namespace ${config.namespace} \\
|
|
1291
|
-
--create-namespace
|
|
1292
|
-
\`\`\`
|
|
1293
|
-
|
|
1294
|
-
## Monitoring
|
|
1295
|
-
|
|
1296
|
-
### Check Deployment Status
|
|
1297
|
-
|
|
1298
|
-
\`\`\`bash
|
|
1299
|
-
# Check pods
|
|
1300
|
-
kubectl get pods -n ${config.namespace}
|
|
1301
|
-
|
|
1302
|
-
# Check deployments
|
|
1303
|
-
kubectl get deployments -n ${config.namespace}
|
|
1304
|
-
|
|
1305
|
-
# Check services
|
|
1306
|
-
kubectl get services -n ${config.namespace}
|
|
1307
|
-
|
|
1308
|
-
${config.setupIngress
|
|
1309
|
-
? `# Check ingress
|
|
1310
|
-
kubectl get ingress -n ${config.namespace}
|
|
1311
|
-
`
|
|
1312
|
-
: ''}
|
|
1313
|
-
# View logs
|
|
1314
|
-
${config.apps.map((app) => `kubectl logs -f deployment/${config.appName}-${app} -n ${config.namespace}`).join('\n')}
|
|
1315
|
-
\`\`\`
|
|
1316
|
-
|
|
1317
|
-
### Scaling
|
|
1318
|
-
|
|
1319
|
-
\`\`\`bash
|
|
1320
|
-
# Scale a deployment
|
|
1321
|
-
${config.apps.map((app) => `kubectl scale deployment/${config.appName}-${app} --replicas=3 -n ${config.namespace}`).join('\n')}
|
|
1322
|
-
\`\`\`
|
|
1323
|
-
|
|
1324
|
-
## Rollback
|
|
1325
|
-
|
|
1326
|
-
\`\`\`bash
|
|
1327
|
-
# View rollout history
|
|
1328
|
-
${config.apps.map((app) => `kubectl rollout history deployment/${config.appName}-${app} -n ${config.namespace}`).join('\n')}
|
|
1329
|
-
|
|
1330
|
-
# Rollback to previous version
|
|
1331
|
-
${config.apps.map((app) => `kubectl rollout undo deployment/${config.appName}-${app} -n ${config.namespace}`).join('\n')}
|
|
1332
|
-
\`\`\`
|
|
1333
|
-
|
|
1334
|
-
## Troubleshooting
|
|
1335
|
-
|
|
1336
|
-
### View Pod Events
|
|
1337
|
-
|
|
1338
|
-
\`\`\`bash
|
|
1339
|
-
kubectl describe pod <pod-name> -n ${config.namespace}
|
|
1340
|
-
\`\`\`
|
|
1341
|
-
|
|
1342
|
-
### View Cluster Events
|
|
1343
|
-
|
|
1344
|
-
\`\`\`bash
|
|
1345
|
-
kubectl get events -n ${config.namespace} --sort-by='.lastTimestamp'
|
|
1346
|
-
\`\`\`
|
|
1347
|
-
|
|
1348
|
-
### Access Pod Shell
|
|
1349
|
-
|
|
1350
|
-
\`\`\`bash
|
|
1351
|
-
${config.apps.map((app) => `kubectl exec -it deployment/${config.appName}-${app} -n ${config.namespace} -- /bin/sh`).join('\n')}
|
|
1352
|
-
\`\`\`
|
|
1353
|
-
|
|
1354
|
-
## URLs
|
|
1355
|
-
|
|
1356
|
-
${config.domain
|
|
1357
|
-
? `- **Admin Panel:** https://${config.domain}
|
|
1358
|
-
${config.apps.includes('api')
|
|
1359
|
-
? `- **API:** https://api.${config.domain}
|
|
1360
|
-
`
|
|
1361
|
-
: ''}`
|
|
1362
|
-
: '- Configure your domain and update DNS records as described above\n'}
|
|
1363
|
-
|
|
1364
|
-
## Further Reading
|
|
1365
|
-
|
|
1366
|
-
- [Digital Ocean Kubernetes Documentation](https://docs.digitalocean.com/products/kubernetes/)
|
|
1367
|
-
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
|
1368
|
-
- [Kubernetes Documentation](https://kubernetes.io/docs/home/)
|
|
1369
|
-
- [Helm Documentation](https://helm.sh/docs/)
|
|
1370
|
-
${config.setupSSL
|
|
1371
|
-
? `- [cert-manager Documentation](https://cert-manager.io/docs/)
|
|
1372
|
-
`
|
|
1373
|
-
: ''}
|
|
1374
|
-
`;
|
|
1262
|
+
const templatePath = pathModule.join(__dirname, '..', '..', 'templates', 'deployment', 'DEPLOYMENT.md.ejs');
|
|
1263
|
+
const templateContent = await (0, promises_1.readFile)(templatePath, 'utf8');
|
|
1264
|
+
const readmeContent = await (0, ejs_1.render)(templateContent, { config });
|
|
1375
1265
|
await (0, promises_1.writeFile)(pathModule.join(path, 'DEPLOYMENT.md'), readmeContent, 'utf8');
|
|
1376
1266
|
this.log(chalk.green('Created DEPLOYMENT.md'));
|
|
1377
1267
|
}
|
|
@@ -1379,6 +1269,10 @@ ${config.setupSSL
|
|
|
1379
1269
|
console.log(chalk.blue.bold('\nš Deployment Configuration Summary\n'));
|
|
1380
1270
|
console.log(chalk.white('Provider: ') + chalk.cyan(config.provider));
|
|
1381
1271
|
console.log(chalk.white('CI/CD: ') + chalk.cyan(config.cicd));
|
|
1272
|
+
if (config.cicd === 'github-actions') {
|
|
1273
|
+
console.log(chalk.white('Deploy Branch: ') +
|
|
1274
|
+
chalk.cyan(config.deployBranch || 'production'));
|
|
1275
|
+
}
|
|
1382
1276
|
console.log(chalk.white('Cluster: ') + chalk.cyan(config.clusterName));
|
|
1383
1277
|
console.log(chalk.white('Namespace: ') +
|
|
1384
1278
|
chalk.cyan(config.namespace) +
|
|
@@ -1389,34 +1283,55 @@ ${config.setupSSL
|
|
|
1389
1283
|
console.log(chalk.white('Domain: ') + chalk.cyan(config.domain));
|
|
1390
1284
|
}
|
|
1391
1285
|
console.log(chalk.white('Apps: ') + chalk.cyan(config.apps.join(', ')));
|
|
1286
|
+
console.log(chalk.white('Replicas: ') +
|
|
1287
|
+
chalk.cyan(config.apps
|
|
1288
|
+
.map((app) => `${app}: ${config.appReplicas?.[app] ?? 2}`)
|
|
1289
|
+
.join(', ')));
|
|
1290
|
+
console.log(chalk.white('Ports: ') +
|
|
1291
|
+
chalk.cyan(config.apps
|
|
1292
|
+
.map((app) => `${app}: ${config.appPorts?.[app] ?? this.getDefaultAppPort(app)}`)
|
|
1293
|
+
.join(', ')));
|
|
1392
1294
|
console.log(chalk.white('Ingress: ') +
|
|
1393
1295
|
chalk.cyan(config.setupIngress ? 'Yes' : 'No'));
|
|
1394
1296
|
console.log(chalk.white('SSL/TLS: ') +
|
|
1395
1297
|
chalk.cyan(config.setupSSL ? 'Yes' : 'No'));
|
|
1396
1298
|
console.log(chalk.green.bold('\nā Generated Files:\n'));
|
|
1397
1299
|
console.log(chalk.gray(' .dockerignore'));
|
|
1398
|
-
config.apps.forEach((app) => {
|
|
1399
|
-
console.log(chalk.gray(` apps/${app}/Dockerfile`));
|
|
1400
|
-
});
|
|
1401
1300
|
console.log(chalk.gray(' .github/workflows/deploy.yml'));
|
|
1402
1301
|
config.apps.forEach((app) => {
|
|
1403
1302
|
console.log(chalk.gray(` k8s/${app}/deployment.yaml`));
|
|
1404
1303
|
console.log(chalk.gray(` k8s/${app}/service.yaml`));
|
|
1405
1304
|
});
|
|
1305
|
+
(config.createdDockerfiles || []).forEach((dockerfilePath) => {
|
|
1306
|
+
console.log(chalk.gray(` ${dockerfilePath}`));
|
|
1307
|
+
});
|
|
1406
1308
|
if (config.setupIngress) {
|
|
1407
1309
|
console.log(chalk.gray(' k8s/ingress.yaml'));
|
|
1408
1310
|
}
|
|
1409
1311
|
if (config.setupSSL) {
|
|
1410
1312
|
console.log(chalk.gray(' k8s/cert-manager-issuer.yaml'));
|
|
1411
1313
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1314
|
+
if ((config.infraServices || []).length > 0) {
|
|
1315
|
+
config.infraServices.forEach((service) => {
|
|
1316
|
+
console.log(chalk.gray(` helm/services/${service.name}/Chart.yaml`));
|
|
1317
|
+
console.log(chalk.gray(` helm/services/${service.name}/values.yaml`));
|
|
1318
|
+
console.log(chalk.gray(` helm/services/${service.name}/templates/deployment.yaml`));
|
|
1319
|
+
console.log(chalk.gray(` helm/services/${service.name}/templates/service.yaml`));
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1414
1322
|
console.log(chalk.gray(' DEPLOYMENT.md'));
|
|
1415
1323
|
console.log(chalk.blue.bold('\nš Next Steps:\n'));
|
|
1416
1324
|
console.log(chalk.white('1. Review the generated files in your project'));
|
|
1417
1325
|
console.log(chalk.white('2. Read DEPLOYMENT.md for setup instructions'));
|
|
1418
1326
|
console.log(chalk.white('3. Configure GitHub secrets (see DEPLOYMENT.md)'));
|
|
1419
|
-
console.log(chalk.white(
|
|
1327
|
+
console.log(chalk.white(`4. Push to ${config.deployBranch || 'production'} branch to trigger deployment`));
|
|
1328
|
+
if ((config.infraServices || []).length > 0) {
|
|
1329
|
+
console.log('');
|
|
1330
|
+
console.log(chalk.blue.bold('ā¹ Additional service charts were generated only (not applied):'));
|
|
1331
|
+
config.infraServices.forEach((service) => {
|
|
1332
|
+
console.log(chalk.white(` helm upgrade --install ${service.name} ./helm/services/${service.name} --namespace ${config.namespace} --create-namespace`));
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1420
1335
|
console.log('');
|
|
1421
1336
|
}
|
|
1422
1337
|
async syncPublish(path, verbose = false) {
|