init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,33 @@
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=cloudhost
|
||||
DB_PASSWORD=cloudhost_secret
|
||||
DB_DATABASE=cloudhost
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Container Registry
|
||||
REGISTRY_URL=registry.example.com
|
||||
REGISTRY_USERNAME=admin
|
||||
REGISTRY_PASSWORD=registry_secret
|
||||
|
||||
# Build
|
||||
BUILD_NAMESPACE=cloudhost-builds
|
||||
BUILD_SERVICE_ACCOUNT=kaniko-builder
|
||||
|
||||
# Platform
|
||||
PLATFORM_DOMAIN=apps.cloudhost.local
|
||||
UPLOAD_DIR=./uploads
|
||||
@@ -0,0 +1,32 @@
|
||||
# ---- Stage 1: Build ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: Production ----
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/templates ./templates
|
||||
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
USER appuser
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
},
|
||||
"sourceRoot": "src",
|
||||
"collection": "@nestjs/schematics",
|
||||
"entryFile": "main"
|
||||
}
|
||||
Generated
+11419
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "cloudhost-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "CloudHost PaaS Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
|
||||
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^0.21.0",
|
||||
"@nestjs/bull": "^10.1.0",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/swagger": "^7.2.0",
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.19",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ApplicationsModule } from './applications/applications.module';
|
||||
import { DeploymentsModule } from './deployments/deployments.module';
|
||||
import { ClustersModule } from './clusters/clusters.module';
|
||||
import { KubernetesModule } from './kubernetes/kubernetes.module';
|
||||
import { BuildModule } from './build/build.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
|
||||
// Database
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('database.host'),
|
||||
port: configService.get('database.port'),
|
||||
username: configService.get('database.username'),
|
||||
password: configService.get('database.password'),
|
||||
database: configService.get('database.name'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: configService.get('nodeEnv') === 'development',
|
||||
logging: configService.get('nodeEnv') === 'development' ? ['error', 'warn'] : false,
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Redis / Bull Queue
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
redis: {
|
||||
host: configService.get('redis.host'),
|
||||
port: configService.get('redis.port'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Feature modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ApplicationsModule,
|
||||
DeploymentsModule,
|
||||
ClustersModule,
|
||||
KubernetesModule,
|
||||
BuildModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Logger,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { DeploymentsService } from '../deployments/deployments.service';
|
||||
|
||||
@ApiTags('Applications')
|
||||
@ApiBearerAuth()
|
||||
@Controller('applications')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class ApplicationsController {
|
||||
private readonly logger = new Logger(ApplicationsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
private readonly kubernetesService: KubernetesService,
|
||||
@Inject(forwardRef(() => DeploymentsService))
|
||||
private readonly deploymentsService: DeploymentsService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new application' })
|
||||
async create(@Request() req: any, @Body() dto: CreateApplicationDto) {
|
||||
return this.applicationsService.create(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/upload')
|
||||
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
}))
|
||||
async uploadCode(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.applicationsService.uploadCode(id, req.user.id, file);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List my applications' })
|
||||
async findAll(@Request() req: any) {
|
||||
if (req.user.role === UserRole.ADMIN) {
|
||||
return this.applicationsService.findAll();
|
||||
}
|
||||
return this.applicationsService.findAllByUser(req.user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get application details' })
|
||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||
if (req.user.role === UserRole.ADMIN) {
|
||||
return this.applicationsService.findOne(id);
|
||||
}
|
||||
return this.applicationsService.findOne(id, req.user.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update application configuration' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@Body() dto: UpdateApplicationDto,
|
||||
) {
|
||||
return this.applicationsService.update(id, req.user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||
async delete(@Param('id') id: string, @Request() req: any) {
|
||||
// 1. Get the app first
|
||||
const app = await this.applicationsService.findOne(id, req.user.id);
|
||||
|
||||
// 2. Delete K8s resources (deployment, service, ingress, db, secrets)
|
||||
try {
|
||||
if (app.clusterId && app.latestImageTag) {
|
||||
await this.kubernetesService.deleteApplication(app);
|
||||
this.logger.log(`Deleted K8s resources for ${app.name}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
// 3. Delete deployment records from DB
|
||||
try {
|
||||
await this.deploymentsService.deleteAllForApplication(app.id);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
// 4. Delete app (also deletes uploaded files)
|
||||
await this.applicationsService.delete(id, req.user.id);
|
||||
|
||||
return { message: `Application "${app.name}" and all resources deleted` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { ApplicationsController } from './applications.controller';
|
||||
import { Application } from './entities/application.entity';
|
||||
import { ClustersModule } from '../clusters/clusters.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Application]),
|
||||
ClustersModule,
|
||||
KubernetesModule,
|
||||
forwardRef(() => DeploymentsModule),
|
||||
],
|
||||
controllers: [ApplicationsController],
|
||||
providers: [ApplicationsService],
|
||||
exports: [ApplicationsService],
|
||||
})
|
||||
export class ApplicationsModule {}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Application } from './entities/application.entity';
|
||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationsService {
|
||||
private readonly logger = new Logger(ApplicationsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Application)
|
||||
private appsRepository: Repository<Application>,
|
||||
private clustersService: ClustersService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateApplicationDto): Promise<Application> {
|
||||
// Auto-assign default cluster if not specified
|
||||
let clusterId = dto.clusterId;
|
||||
if (!clusterId) {
|
||||
try {
|
||||
const defaultCluster = await this.clustersService.getDefault();
|
||||
clusterId = defaultCluster.id;
|
||||
this.logger.log(`Auto-assigned default cluster "${defaultCluster.name}" to app "${dto.name}"`);
|
||||
} catch {
|
||||
this.logger.warn('No default cluster found — app will be created without cluster assignment');
|
||||
}
|
||||
}
|
||||
|
||||
const app = this.appsRepository.create({
|
||||
...dto,
|
||||
userId,
|
||||
clusterId,
|
||||
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
||||
});
|
||||
return this.appsRepository.save(app);
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Application[]> {
|
||||
return this.appsRepository.find({
|
||||
where: { userId },
|
||||
relations: ['deployments'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(): Promise<Application[]> {
|
||||
return this.appsRepository.find({
|
||||
relations: ['user', 'deployments'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, userId?: string): Promise<Application> {
|
||||
const where: any = { id };
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
|
||||
const app = await this.appsRepository.findOne({
|
||||
where,
|
||||
relations: ['deployments'],
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateApplicationDto): Promise<Application> {
|
||||
const app = await this.findOne(id, userId);
|
||||
Object.assign(app, dto);
|
||||
return this.appsRepository.save(app);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<Application> {
|
||||
const app = await this.findOne(id, userId);
|
||||
|
||||
// Delete uploaded files
|
||||
if (app.codePath) {
|
||||
try {
|
||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||
if (fs.existsSync(appDir)) {
|
||||
fs.rmSync(appDir, { recursive: true, force: true });
|
||||
this.logger.log(`Deleted upload directory: ${appDir}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.appsRepository.remove(app);
|
||||
this.logger.log(`Deleted application ${app.name} (${id})`);
|
||||
return app;
|
||||
}
|
||||
|
||||
async updateImageTag(id: string, imageTag: string): Promise<Application> {
|
||||
const app = await this.findOne(id);
|
||||
app.latestImageTag = imageTag;
|
||||
return this.appsRepository.save(app);
|
||||
}
|
||||
|
||||
async uploadCode(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
|
||||
const app = await this.findOne(id, userId);
|
||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||
|
||||
// Ensure directory exists
|
||||
fs.mkdirSync(appDir, { recursive: true });
|
||||
|
||||
// Save the zip file
|
||||
const zipPath = path.join(appDir, 'source.zip');
|
||||
fs.writeFileSync(zipPath, file.buffer);
|
||||
|
||||
// Update app with code path
|
||||
app.codePath = zipPath;
|
||||
const saved = await this.appsRepository.save(app);
|
||||
|
||||
this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
Min,
|
||||
Max,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AppRuntime, DatabaseType } from '../../common/enums';
|
||||
|
||||
export class CreateApplicationDto {
|
||||
@ApiProperty({ example: 'my-app' })
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
|
||||
message: 'Name must be lowercase alphanumeric with hyphens only',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'My awesome Node.js application' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ enum: AppRuntime, example: AppRuntime.NODEJS })
|
||||
@IsEnum(AppRuntime)
|
||||
runtime: AppRuntime;
|
||||
|
||||
@ApiProperty({ enum: DatabaseType, example: DatabaseType.POSTGRESQL })
|
||||
@IsEnum(DatabaseType)
|
||||
databaseType: DatabaseType;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gitUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: { NODE_ENV: 'production', PORT: '3000' } })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
envVars?: Record<string, string>;
|
||||
|
||||
@ApiPropertyOptional({ example: '250m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '500m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '256Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '512Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 2, minimum: 1, maximum: 10 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
replicas?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3000 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
port?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Cluster ID to deploy to (auto-assigns default if empty)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clusterId?: string;
|
||||
}
|
||||
|
||||
export class UpdateApplicationDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
envVars?: Record<string, string>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuRequest?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryRequest?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryLimit?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
replicas?: number;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { AppRuntime, DatabaseType } from '../../common/enums';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Deployment } from '../../deployments/entities/deployment.entity';
|
||||
|
||||
@Entity('applications')
|
||||
export class Application {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'enum', enum: AppRuntime })
|
||||
runtime: AppRuntime;
|
||||
|
||||
@Column({ type: 'enum', enum: DatabaseType, default: DatabaseType.NONE })
|
||||
databaseType: DatabaseType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
gitUrl: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
codePath: string; // Path to uploaded zip
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
envVars: Record<string, string>;
|
||||
|
||||
// Resource configuration
|
||||
@Column({ default: '100m' })
|
||||
cpuRequest: string;
|
||||
|
||||
@Column({ default: '500m' })
|
||||
cpuLimit: string;
|
||||
|
||||
@Column({ default: '128Mi' })
|
||||
memoryRequest: string;
|
||||
|
||||
@Column({ default: '512Mi' })
|
||||
memoryLimit: string;
|
||||
|
||||
@Column({ default: 1 })
|
||||
replicas: number;
|
||||
|
||||
@Column({ default: 3000 })
|
||||
port: number;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => User, (user: User) => user.applications, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
clusterId: string;
|
||||
|
||||
@OneToMany(() => Deployment, (deployment: Deployment) => deployment.application)
|
||||
deployments: Deployment[];
|
||||
|
||||
// Metadata
|
||||
@Column({ nullable: true })
|
||||
latestImageTag: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
subdomain: string; // <subdomain>.apps.cloudhost.local
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiResponse({ status: 201, description: 'User registered successfully' })
|
||||
@ApiResponse({ status: 409, description: 'Email already registered' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiResponse({ status: 200, description: 'Login successful' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiResponse({ status: 200, description: 'Token refreshed' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('jwt.secret'),
|
||||
signOptions: { expiresIn: configService.get('jwt.expiresIn') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async register(registerDto: RegisterDto) {
|
||||
const existingUser = await this.usersService.findByEmail(registerDto.email);
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||
const user = await this.usersService.create({
|
||||
...registerDto,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto) {
|
||||
const user = await this.usersService.findByEmail(loginDto.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Account is deactivated');
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
});
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return this.generateTokens(user.id, user.email, user.role);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
private async generateTokens(userId: string, email: string, role: string) {
|
||||
const payload = { sub: userId, email, role };
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecureP@ss123' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecureP@ss123' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(64)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
lastName: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { BuildService } from './build.service';
|
||||
import { BuildProcessor } from './build.processor';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { ClustersModule } from '../clusters/clusters.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({ name: 'build' }),
|
||||
forwardRef(() => KubernetesModule),
|
||||
ClustersModule,
|
||||
],
|
||||
providers: [BuildService, BuildProcessor],
|
||||
exports: [BuildService],
|
||||
})
|
||||
export class BuildModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
import { BuildService } from './build.service';
|
||||
|
||||
export interface BuildJobData {
|
||||
applicationId: string;
|
||||
deploymentId: string;
|
||||
appName: string;
|
||||
runtime: string;
|
||||
gitUrl?: string;
|
||||
codePath?: string;
|
||||
}
|
||||
|
||||
@Processor('build')
|
||||
export class BuildProcessor {
|
||||
private readonly logger = new Logger(BuildProcessor.name);
|
||||
|
||||
constructor(private buildService: BuildService) {}
|
||||
|
||||
@Process('build-image')
|
||||
async handleBuild(job: Job<BuildJobData>) {
|
||||
this.logger.log(`Processing build job ${job.id} for app: ${job.data.appName}`);
|
||||
|
||||
try {
|
||||
await job.progress(10);
|
||||
|
||||
// The actual build logic is in BuildService
|
||||
// This processor handles the queue job lifecycle
|
||||
this.logger.log(`Build job ${job.id} started for ${job.data.appName}`);
|
||||
|
||||
await job.progress(50);
|
||||
await job.progress(100);
|
||||
|
||||
this.logger.log(`Build job ${job.id} completed for ${job.data.appName}`);
|
||||
return { status: 'completed', appName: job.data.appName };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Build job ${job.id} failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
|
||||
@Injectable()
|
||||
export class BuildService {
|
||||
private readonly logger = new Logger(BuildService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private clustersService: ClustersService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Builds a Docker image for the application using Kaniko inside K8s.
|
||||
* Returns the full image URI (registry/repo:tag).
|
||||
*/
|
||||
async buildImage(app: Application): Promise<string> {
|
||||
// Internal registry (used by Kaniko inside K8s for pushing)
|
||||
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
||||
// External registry URL (used by kubelet for pulling — NodePort or external)
|
||||
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
|
||||
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
|
||||
const tag = `${Date.now()}`;
|
||||
const pushImageUri = `${internalRegistryUrl}/${app.userId}/${app.name}:${tag}`;
|
||||
const pullImageUri = `${pullRegistryUrl}/${app.userId}/${app.name}:${tag}`;
|
||||
|
||||
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
|
||||
|
||||
// Determine Dockerfile based on runtime
|
||||
const dockerfileContent = this.generateDockerfile(app);
|
||||
|
||||
// Create Kaniko build pod
|
||||
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
// Use the cluster's kubeconfig instead of default
|
||||
const cluster = app.clusterId
|
||||
? await this.clustersService.findOne(app.clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
|
||||
// Determine if we have uploaded code or git URL
|
||||
const codePath = app.codePath ? path.resolve(app.codePath) : null;
|
||||
const hasUploadedCode = codePath && fs.existsSync(codePath);
|
||||
const hasGitUrl = !!app.gitUrl;
|
||||
|
||||
// Create ConfigMap with Dockerfile
|
||||
const dockerfileConfigMap = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'ConfigMap',
|
||||
metadata: {
|
||||
name: `${buildPodName}-dockerfile`,
|
||||
namespace: buildNamespace,
|
||||
},
|
||||
data: {
|
||||
Dockerfile: dockerfileContent,
|
||||
},
|
||||
};
|
||||
|
||||
// If we have uploaded code, create a ConfigMap with the zip as base64
|
||||
let sourceConfigMapName: string | undefined;
|
||||
if (hasUploadedCode) {
|
||||
const zipBuffer = fs.readFileSync(codePath!);
|
||||
const zipBase64 = zipBuffer.toString('base64');
|
||||
|
||||
sourceConfigMapName = `${buildPodName}-source`;
|
||||
|
||||
// ConfigMap has 1MB limit, for larger files we'd need a PVC approach
|
||||
// For now, use a Secret (which can hold up to 1MB too, but binary-safe)
|
||||
const sourceSecret = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: sourceConfigMapName,
|
||||
namespace: buildNamespace,
|
||||
},
|
||||
data: {
|
||||
'source.zip': zipBase64,
|
||||
},
|
||||
};
|
||||
|
||||
await coreApi.createNamespacedSecret(buildNamespace!, sourceSecret);
|
||||
this.logger.log(`Created source secret: ${sourceConfigMapName} (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
|
||||
// Build the Kaniko Job spec
|
||||
// Always use dir context — init containers prepare /workspace/source
|
||||
const kanikoArgs = [
|
||||
'--dockerfile=/workspace/Dockerfile',
|
||||
'--context=dir:///workspace/source',
|
||||
`--destination=${pushImageUri}`,
|
||||
'--cache=true',
|
||||
`--cache-repo=${internalRegistryUrl}/${app.userId}/cache`,
|
||||
'--insecure',
|
||||
'--skip-tls-verify',
|
||||
];
|
||||
|
||||
const volumes: any[] = [
|
||||
{
|
||||
name: 'docker-config',
|
||||
secret: { secretName: 'registry-credentials' },
|
||||
},
|
||||
{
|
||||
name: 'dockerfile',
|
||||
configMap: {
|
||||
name: `${buildPodName}-dockerfile`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'workspace',
|
||||
emptyDir: {},
|
||||
},
|
||||
];
|
||||
|
||||
const initContainers: any[] = [];
|
||||
|
||||
if (hasUploadedCode && sourceConfigMapName) {
|
||||
// Add the source secret as a volume
|
||||
volumes.push({
|
||||
name: 'source-zip',
|
||||
secret: { secretName: sourceConfigMapName },
|
||||
});
|
||||
|
||||
// Add init container that unzips the source code
|
||||
initContainers.push({
|
||||
name: 'unzip-source',
|
||||
image: 'alpine:3.19',
|
||||
command: ['sh', '-c', `
|
||||
apk add --no-cache unzip &&
|
||||
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
||||
mkdir -p /workspace-out/source &&
|
||||
cd /workspace-out/source &&
|
||||
unzip /source/source.zip &&
|
||||
ls -la /workspace-out/source/
|
||||
`],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' },
|
||||
{ name: 'source-zip', mountPath: '/source' },
|
||||
],
|
||||
});
|
||||
} else if (hasGitUrl) {
|
||||
// Clone git repo into /workspace/source, then copy our generated Dockerfile
|
||||
initContainers.push({
|
||||
name: 'git-clone',
|
||||
image: 'alpine/git:2.43.0',
|
||||
command: ['sh', '-c', `
|
||||
echo ">>> Cloning ${app.gitUrl}" &&
|
||||
git clone --depth 1 ${app.gitUrl} /workspace-out/source &&
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||
echo ">>> Workspace contents:" &&
|
||||
ls -la /workspace-out/source/
|
||||
`],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/dockerfile' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Kaniko container volume mounts
|
||||
const kanikoVolumeMounts: any[] = [
|
||||
{ name: 'docker-config', mountPath: '/kaniko/.docker' },
|
||||
{ name: 'workspace', mountPath: '/workspace' },
|
||||
];
|
||||
|
||||
// If no uploaded code and no git, mount dockerfile directly
|
||||
if (!hasUploadedCode && !hasGitUrl) {
|
||||
kanikoVolumeMounts.push({
|
||||
name: 'dockerfile',
|
||||
mountPath: '/workspace/Dockerfile',
|
||||
subPath: 'Dockerfile',
|
||||
});
|
||||
}
|
||||
|
||||
const buildJob: k8s.V1Job = {
|
||||
apiVersion: 'batch/v1',
|
||||
kind: 'Job',
|
||||
metadata: {
|
||||
name: buildPodName,
|
||||
namespace: buildNamespace,
|
||||
},
|
||||
spec: {
|
||||
backoffLimit: 2,
|
||||
ttlSecondsAfterFinished: 300,
|
||||
template: {
|
||||
spec: {
|
||||
serviceAccountName: this.configService.get<string>('build.serviceAccount'),
|
||||
initContainers: initContainers.length > 0 ? initContainers : undefined,
|
||||
containers: [
|
||||
{
|
||||
name: 'kaniko',
|
||||
image: 'gcr.io/kaniko-project/executor:latest',
|
||||
args: kanikoArgs,
|
||||
volumeMounts: kanikoVolumeMounts,
|
||||
resources: {
|
||||
requests: { cpu: '500m', memory: '1Gi' },
|
||||
limits: { cpu: '2', memory: '4Gi' },
|
||||
},
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
volumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.createNamespacedConfigMap(buildNamespace!, dockerfileConfigMap);
|
||||
await batchApi.createNamespacedJob(buildNamespace!, buildJob);
|
||||
|
||||
// Wait for build to complete
|
||||
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
|
||||
|
||||
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
||||
return pullImageUri;
|
||||
} catch (error: any) {
|
||||
// Try to get build logs for debugging
|
||||
try {
|
||||
const logs = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
|
||||
this.logger.error(`Build logs for ${buildPodName}:\n${logs}`);
|
||||
} catch {}
|
||||
|
||||
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
|
||||
throw new Error(`Image build failed: ${error.body?.message || error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateDockerfile(app: Application): string {
|
||||
switch (app.runtime) {
|
||||
case AppRuntime.NODEJS:
|
||||
return this.nodeDockerfile(app);
|
||||
case AppRuntime.LARAVEL:
|
||||
return this.laravelDockerfile(app);
|
||||
default:
|
||||
throw new Error(`Unsupported runtime: ${app.runtime}`);
|
||||
}
|
||||
}
|
||||
|
||||
private nodeDockerfile(app: Application): string {
|
||||
const port = app.port || 3000;
|
||||
return `# --- Build stage ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm cache clean --force
|
||||
COPY . .
|
||||
|
||||
# Auto-detect Next.js and enable standalone output
|
||||
RUN if ([ -f next.config.js ] || [ -f next.config.mjs ] || [ -f next.config.ts ]); then \\
|
||||
echo ">>> Next.js detected, injecting standalone output"; \\
|
||||
node -e " \\
|
||||
const fs = require('fs'); \\
|
||||
const files = ['next.config.js','next.config.mjs','next.config.ts']; \\
|
||||
for (const f of files) { \\
|
||||
if (fs.existsSync(f)) { \\
|
||||
let c = fs.readFileSync(f,'utf8'); \\
|
||||
if (!c.includes('standalone')) { \\
|
||||
c = c.replace(/output\\s*:\\s*['\\\"][^'\\\"]*['\\\"]\\s*,?/g, ''); \\
|
||||
c = c.replace(/(\\{)/, '\\$1 output: \\\"standalone\\\",'); \\
|
||||
fs.writeFileSync(f, c); \\
|
||||
console.log('Patched ' + f + ' with standalone output'); \\
|
||||
} else { \\
|
||||
console.log(f + ' already has standalone'); \\
|
||||
} \\
|
||||
break; \\
|
||||
} \\
|
||||
} \\
|
||||
"; \\
|
||||
fi
|
||||
|
||||
RUN npm run build 2>/dev/null || true
|
||||
|
||||
# --- Production stage ---
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
|
||||
|
||||
# Copy all build output to temp
|
||||
COPY --from=builder /app /tmp/fullapp
|
||||
|
||||
# Detect: Next.js standalone vs regular Node.js
|
||||
RUN if [ -d /tmp/fullapp/.next/standalone ]; then \\
|
||||
echo ">>> Next.js standalone mode"; \\
|
||||
cp -a /tmp/fullapp/.next/standalone/. .; \\
|
||||
mkdir -p .next/static; \\
|
||||
[ -d /tmp/fullapp/.next/static ] && cp -a /tmp/fullapp/.next/static/. .next/static/; \\
|
||||
[ -d /tmp/fullapp/public ] && cp -a /tmp/fullapp/public ./public; \\
|
||||
echo "standalone" > /app/.mode; \\
|
||||
else \\
|
||||
echo ">>> Regular Node.js app"; \\
|
||||
cp -a /tmp/fullapp/. .; \\
|
||||
echo "regular" > /app/.mode; \\
|
||||
fi && rm -rf /tmp/fullapp
|
||||
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
EXPOSE ${port}
|
||||
CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f server.js ]; then node server.js; else npm start; fi"]
|
||||
`;
|
||||
}
|
||||
|
||||
private laravelDockerfile(app: Application): string {
|
||||
return `# --- Build stage ---
|
||||
FROM composer:2 AS composer
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
|
||||
COPY . .
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
# --- Production stage ---
|
||||
FROM php:8.3-fpm-alpine
|
||||
RUN apk add --no-cache nginx supervisor \\
|
||||
&& docker-php-ext-install pdo pdo_mysql pdo_pgsql opcache
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY --from=composer /app .
|
||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache || true
|
||||
|
||||
EXPOSE ${app.port || 8000}
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
`;
|
||||
}
|
||||
|
||||
private async waitForJobCompletion(
|
||||
batchApi: k8s.BatchV1Api,
|
||||
coreApi: k8s.CoreV1Api,
|
||||
jobName: string,
|
||||
namespace: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const job = await batchApi.readNamespacedJob(jobName, namespace);
|
||||
const status = job.body.status;
|
||||
|
||||
if (status?.succeeded && status.succeeded > 0) {
|
||||
return; // Build completed
|
||||
}
|
||||
|
||||
if (status?.failed && status.failed > 0) {
|
||||
// Try to get pod logs for more info
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
|
||||
}
|
||||
|
||||
// Wait 5 seconds before polling again
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s`);
|
||||
}
|
||||
|
||||
private async getBuildLogs(
|
||||
coreApi: k8s.CoreV1Api,
|
||||
jobName: string,
|
||||
namespace: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const pods = await coreApi.listNamespacedPod(
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`job-name=${jobName}`,
|
||||
);
|
||||
|
||||
if (pods.body.items.length === 0) {
|
||||
return 'No pods found for build job.';
|
||||
}
|
||||
|
||||
const podName = pods.body.items[0].metadata?.name;
|
||||
if (!podName) return 'Pod name not found.';
|
||||
|
||||
// Get logs from all containers (init + kaniko)
|
||||
let allLogs = '';
|
||||
const containers = [
|
||||
...(pods.body.items[0].spec?.initContainers || []),
|
||||
...(pods.body.items[0].spec?.containers || []),
|
||||
];
|
||||
|
||||
for (const container of containers) {
|
||||
try {
|
||||
const logResponse = await coreApi.readNamespacedPodLog(
|
||||
podName,
|
||||
namespace,
|
||||
container.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
|
||||
} catch {
|
||||
allLogs += `\n--- ${container.name} --- (no logs available)`;
|
||||
}
|
||||
}
|
||||
return allLogs;
|
||||
} catch (e: any) {
|
||||
return `Failed to retrieve logs: ${e.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ClustersService } from './clusters.service';
|
||||
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@ApiTags('Clusters')
|
||||
@ApiBearerAuth()
|
||||
@Controller('clusters')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
export class ClustersController {
|
||||
constructor(private readonly clustersService: ClustersService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin only)' })
|
||||
async create(@Body() dto: CreateClusterDto) {
|
||||
return this.clustersService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all clusters (Admin only)' })
|
||||
async findAll() {
|
||||
return this.clustersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get cluster details (Admin only)' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.clustersService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update cluster configuration (Admin only)' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
|
||||
return this.clustersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin only)' })
|
||||
async testConnection(@Param('id') id: string) {
|
||||
return this.clustersService.testClusterById(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Remove a cluster (Admin only)' })
|
||||
async delete(@Param('id') id: string) {
|
||||
await this.clustersService.delete(id);
|
||||
return { message: 'Cluster deleted' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClustersService } from './clusters.service';
|
||||
import { ClustersController } from './clusters.controller';
|
||||
import { Cluster } from './entities/cluster.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Cluster])],
|
||||
controllers: [ClustersController],
|
||||
providers: [ClustersService],
|
||||
exports: [ClustersService],
|
||||
})
|
||||
export class ClustersModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import { Cluster } from './entities/cluster.entity';
|
||||
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||
import { ClusterStatus } from '../common/enums';
|
||||
|
||||
@Injectable()
|
||||
export class ClustersService {
|
||||
private readonly logger = new Logger(ClustersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Cluster)
|
||||
private clustersRepository: Repository<Cluster>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Test connection to a Kubernetes cluster using its kubeconfig.
|
||||
* Calls the /version endpoint to verify the cluster is reachable.
|
||||
*/
|
||||
async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(kubeconfig);
|
||||
|
||||
const versionApi = kc.makeApiClient(k8s.VersionApi);
|
||||
const result = await versionApi.getCode();
|
||||
const info = result.body;
|
||||
|
||||
this.logger.log(`Cluster connection OK: Kubernetes ${info.gitVersion}`);
|
||||
return {
|
||||
connected: true,
|
||||
version: info.gitVersion,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const message = err?.body?.message || err?.message || 'Unknown connection error';
|
||||
this.logger.warn(`Cluster connection failed: ${message}`);
|
||||
return {
|
||||
connected: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateClusterDto): Promise<Cluster> {
|
||||
// Validate kubeconfig by testing actual connection
|
||||
const connectionTest = await this.testConnection(dto.kubeconfig);
|
||||
if (!connectionTest.connected) {
|
||||
throw new BadRequestException(
|
||||
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
|
||||
for (const c of existingDefaults) {
|
||||
c.isDefault = false;
|
||||
await this.clustersRepository.save(c);
|
||||
}
|
||||
}
|
||||
|
||||
const cluster = this.clustersRepository.create({
|
||||
...dto,
|
||||
status: ClusterStatus.ACTIVE, // Connection verified — mark active
|
||||
});
|
||||
const saved = await this.clustersRepository.save(cluster);
|
||||
this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Cluster[]> {
|
||||
return this.clustersRepository.find({
|
||||
select: ['id', 'name', 'description', 'status', 'apiServer', 'region', 'provider', 'isDefault', 'createdAt'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Cluster> {
|
||||
const cluster = await this.clustersRepository.findOne({ where: { id } });
|
||||
if (!cluster) {
|
||||
throw new NotFoundException('Cluster not found');
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
|
||||
async getDefault(): Promise<Cluster> {
|
||||
const cluster = await this.clustersRepository.findOne({ where: { isDefault: true } });
|
||||
if (!cluster) {
|
||||
throw new NotFoundException('No default cluster configured');
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateClusterDto): Promise<Cluster> {
|
||||
const cluster = await this.findOne(id);
|
||||
|
||||
// If kubeconfig is being updated, re-test connection
|
||||
if (dto.kubeconfig) {
|
||||
const connectionTest = await this.testConnection(dto.kubeconfig);
|
||||
if (!connectionTest.connected) {
|
||||
throw new BadRequestException(
|
||||
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
|
||||
);
|
||||
}
|
||||
dto.status = ClusterStatus.ACTIVE;
|
||||
this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`);
|
||||
}
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
|
||||
for (const c of existingDefaults) {
|
||||
if (c.id !== id) {
|
||||
c.isDefault = false;
|
||||
await this.clustersRepository.save(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.assign(cluster, dto);
|
||||
return this.clustersRepository.save(cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually test connectivity to an existing cluster.
|
||||
* Updates status to active/inactive based on result.
|
||||
*/
|
||||
async testClusterById(id: string): Promise<{ connected: boolean; version?: string; error?: string }> {
|
||||
const cluster = await this.findOne(id);
|
||||
const result = await this.testConnection(cluster.kubeconfig);
|
||||
|
||||
cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE;
|
||||
await this.clustersRepository.save(cluster);
|
||||
this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const cluster = await this.findOne(id);
|
||||
await this.clustersRepository.remove(cluster);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ClusterStatus } from '../../common/enums';
|
||||
|
||||
export class CreateClusterDto {
|
||||
@ApiProperty({ example: 'production-cluster' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Main production K8s cluster' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: 'apiVersion: v1\nclusters:\n- cluster:...' })
|
||||
@IsString()
|
||||
kubeconfig: string;
|
||||
|
||||
@ApiProperty({ example: 'https://k8s-api.example.com:6443' })
|
||||
@IsString()
|
||||
apiServer: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'us-east-1' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
region?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'aws' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
provider?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: '4' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultCpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '8Gi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultMemoryLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 10 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
maxAppsPerUser?: number;
|
||||
}
|
||||
|
||||
export class UpdateClusterDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEnum(ClusterStatus)
|
||||
status?: ClusterStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
kubeconfig?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultCpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultMemoryLimit?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
maxAppsPerUser?: number;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ClusterStatus } from '../../common/enums';
|
||||
|
||||
@Entity('clusters')
|
||||
export class Cluster {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ClusterStatus, default: ClusterStatus.ACTIVE })
|
||||
status: ClusterStatus;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
kubeconfig: string; // Encrypted kubeconfig content
|
||||
|
||||
@Column()
|
||||
apiServer: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
region: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal'
|
||||
|
||||
@Column({ default: false })
|
||||
isDefault: boolean;
|
||||
|
||||
// Resource quotas (cluster-level defaults for new namespaces)
|
||||
@Column({ default: '4' })
|
||||
defaultCpuLimit: string;
|
||||
|
||||
@Column({ default: '8Gi' })
|
||||
defaultMemoryLimit: string;
|
||||
|
||||
@Column({ default: 10 })
|
||||
maxAppsPerUser: number;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../enums';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,41 @@
|
||||
// Shared enums used across the platform
|
||||
|
||||
export enum UserRole {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
export enum AppRuntime {
|
||||
NODEJS = 'nodejs',
|
||||
LARAVEL = 'laravel',
|
||||
}
|
||||
|
||||
export enum DatabaseType {
|
||||
MYSQL = 'mysql',
|
||||
POSTGRESQL = 'postgresql',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum DeploymentStatus {
|
||||
PENDING = 'pending',
|
||||
BUILDING = 'building',
|
||||
BUILD_FAILED = 'build_failed',
|
||||
DEPLOYING = 'deploying',
|
||||
RUNNING = 'running',
|
||||
FAILED = 'failed',
|
||||
STOPPED = 'stopped',
|
||||
DELETING = 'deleting',
|
||||
}
|
||||
|
||||
export enum ClusterStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
MAINTENANCE = 'maintenance',
|
||||
}
|
||||
|
||||
export enum BuildStatus {
|
||||
QUEUED = 'queued',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
SUCCESS = 'success',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '../enums';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user.role === role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export default () => ({
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '4000', 10),
|
||||
|
||||
database: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
username: process.env.DB_USERNAME || 'cloudhost',
|
||||
password: process.env.DB_PASSWORD || 'cloudhost_secret',
|
||||
name: process.env.DB_DATABASE || 'cloudhost',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'default-jwt-secret',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
},
|
||||
|
||||
registry: {
|
||||
url: process.env.REGISTRY_URL || 'registry.example.com',
|
||||
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500',
|
||||
username: process.env.REGISTRY_USERNAME || 'admin',
|
||||
password: process.env.REGISTRY_PASSWORD || '',
|
||||
},
|
||||
|
||||
build: {
|
||||
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
|
||||
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
|
||||
},
|
||||
|
||||
platform: {
|
||||
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
||||
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
|
||||
@ApiTags('Deployments')
|
||||
@ApiBearerAuth()
|
||||
@Controller('deployments')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class DeploymentsController {
|
||||
constructor(private readonly deploymentsService: DeploymentsService) {}
|
||||
|
||||
@Post('applications/:appId/deploy')
|
||||
@ApiOperation({ summary: 'Trigger a new deployment' })
|
||||
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
|
||||
return this.deploymentsService.triggerDeployment(appId, req.user.id);
|
||||
}
|
||||
|
||||
@Get('applications/:appId')
|
||||
@ApiOperation({ summary: 'List deployments for an application' })
|
||||
async findByApplication(@Param('appId') appId: string) {
|
||||
return this.deploymentsService.findByApplication(appId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get deployment details' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.deploymentsService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('applications/:appId/logs')
|
||||
@ApiOperation({ summary: 'Get application logs' })
|
||||
async getLogs(@Param('appId') appId: string, @Request() req: any) {
|
||||
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
|
||||
}
|
||||
|
||||
@Post('applications/:appId/stop')
|
||||
@ApiOperation({ summary: 'Stop an application' })
|
||||
async stop(@Param('appId') appId: string, @Request() req: any) {
|
||||
const deployment = await this.deploymentsService.stopDeployment(appId, req.user.id);
|
||||
return { message: 'Application stopped', deployment };
|
||||
}
|
||||
|
||||
@Post('applications/:appId/start')
|
||||
@ApiOperation({ summary: 'Start a stopped application' })
|
||||
async start(@Param('appId') appId: string, @Request() req: any) {
|
||||
const deployment = await this.deploymentsService.startDeployment(appId, req.user.id);
|
||||
return { message: 'Application started', deployment };
|
||||
}
|
||||
|
||||
@Post('applications/:appId/restart')
|
||||
@ApiOperation({ summary: 'Restart an application' })
|
||||
async restart(@Param('appId') appId: string, @Request() req: any) {
|
||||
await this.deploymentsService.restartDeployment(appId, req.user.id);
|
||||
return { message: 'Application restarted' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { DeploymentsController } from './deployments.controller';
|
||||
import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsModule } from '../applications/applications.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { BuildModule } from '../build/build.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Deployment]),
|
||||
forwardRef(() => ApplicationsModule),
|
||||
KubernetesModule,
|
||||
BuildModule,
|
||||
],
|
||||
controllers: [DeploymentsController],
|
||||
providers: [DeploymentsService],
|
||||
exports: [DeploymentsService],
|
||||
})
|
||||
export class DeploymentsModule {}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Injectable, NotFoundException, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService } from '../build/build.service';
|
||||
import { DeploymentStatus } from '../common/enums';
|
||||
|
||||
@Injectable()
|
||||
export class DeploymentsService {
|
||||
private readonly logger = new Logger(DeploymentsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Deployment)
|
||||
private deploymentsRepository: Repository<Deployment>,
|
||||
@Inject(forwardRef(() => ApplicationsService))
|
||||
private applicationsService: ApplicationsService,
|
||||
private kubernetesService: KubernetesService,
|
||||
private buildService: BuildService,
|
||||
) {}
|
||||
|
||||
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
|
||||
// Create deployment record
|
||||
const deployment = this.deploymentsRepository.create({
|
||||
applicationId: app.id,
|
||||
triggeredBy: userId,
|
||||
imageTag: `${app.name}:${Date.now()}`,
|
||||
status: DeploymentStatus.PENDING,
|
||||
version: `v${Date.now()}`,
|
||||
});
|
||||
const saved = await this.deploymentsRepository.save(deployment);
|
||||
|
||||
// Trigger async build & deploy pipeline
|
||||
this.executePipeline(saved.id, app).catch((error) => {
|
||||
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async executePipeline(deploymentId: string, app: any): Promise<void> {
|
||||
try {
|
||||
// Step 1: Build image
|
||||
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
||||
const imageUri = await this.buildService.buildImage(app);
|
||||
|
||||
// Step 2: Update app with new image tag
|
||||
await this.applicationsService.updateImageTag(app.id, imageUri);
|
||||
|
||||
// Step 3: Deploy to Kubernetes
|
||||
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
|
||||
const k8sResources = await this.kubernetesService.deployApplication(app, imageUri);
|
||||
|
||||
// Step 4: Mark success
|
||||
await this.deploymentsRepository.update(deploymentId, {
|
||||
status: DeploymentStatus.RUNNING,
|
||||
k8sResources,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Deployment ${deploymentId} failed:`, error);
|
||||
await this.deploymentsRepository.update(deploymentId, {
|
||||
status: DeploymentStatus.FAILED,
|
||||
errorMessage: error.message,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
|
||||
await this.deploymentsRepository.update(id, { status });
|
||||
}
|
||||
|
||||
async findByApplication(applicationId: string): Promise<Deployment[]> {
|
||||
return this.deploymentsRepository.find({
|
||||
where: { applicationId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Deployment> {
|
||||
const deployment = await this.deploymentsRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['application'],
|
||||
});
|
||||
if (!deployment) {
|
||||
throw new NotFoundException('Deployment not found');
|
||||
}
|
||||
return deployment;
|
||||
}
|
||||
|
||||
async getLogs(applicationId: string, userId: string): Promise<string> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
return this.kubernetesService.getPodLogs(app);
|
||||
}
|
||||
|
||||
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
await this.kubernetesService.scaleDeployment(app, 0);
|
||||
|
||||
// Update the latest deployment status to stopped
|
||||
const latest = await this.deploymentsRepository.findOne({
|
||||
where: { applicationId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (latest) {
|
||||
latest.status = DeploymentStatus.STOPPED;
|
||||
await this.deploymentsRepository.save(latest);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
async startDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
await this.kubernetesService.scaleDeployment(app, app.replicas || 1);
|
||||
|
||||
// Update the latest deployment status to running
|
||||
const latest = await this.deploymentsRepository.findOne({
|
||||
where: { applicationId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (latest) {
|
||||
latest.status = DeploymentStatus.RUNNING;
|
||||
await this.deploymentsRepository.save(latest);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
async restartDeployment(applicationId: string, userId: string): Promise<void> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
await this.kubernetesService.restartDeployment(app);
|
||||
}
|
||||
|
||||
async deleteAllForApplication(applicationId: string): Promise<void> {
|
||||
await this.deploymentsRepository.delete({ applicationId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { DeploymentStatus } from '../../common/enums';
|
||||
import { Application } from '../../applications/entities/application.entity';
|
||||
|
||||
@Entity('deployments')
|
||||
export class Deployment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: DeploymentStatus, default: DeploymentStatus.PENDING })
|
||||
status: DeploymentStatus;
|
||||
|
||||
@Column()
|
||||
imageTag: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
version: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
k8sResources: Record<string, any>; // Snapshot of generated K8s manifests
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
buildLog: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
deployLog: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
errorMessage: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Application, (app: Application) => app.deployments, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Application;
|
||||
|
||||
@Column()
|
||||
applicationId: string;
|
||||
|
||||
@Column()
|
||||
triggeredBy: string; // userId who triggered the deployment
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
finishedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { KubernetesService } from './kubernetes.service';
|
||||
import { ClustersModule } from '../clusters/clusters.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ClustersModule)],
|
||||
providers: [KubernetesService],
|
||||
exports: [KubernetesService],
|
||||
})
|
||||
export class KubernetesModule {}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime, DatabaseType } from '../common/enums';
|
||||
|
||||
interface ManifestContext {
|
||||
appName: string;
|
||||
namespace: string;
|
||||
image: string;
|
||||
port: number;
|
||||
replicas: number;
|
||||
cpuRequest: string;
|
||||
cpuLimit: string;
|
||||
memoryRequest: string;
|
||||
memoryLimit: string;
|
||||
envVars: Record<string, string>;
|
||||
runtime: AppRuntime;
|
||||
databaseType: DatabaseType;
|
||||
domain: string;
|
||||
subdomain: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KubernetesService implements OnModuleInit {
|
||||
private readonly logger = new Logger(KubernetesService.name);
|
||||
private templates: Map<string, Handlebars.TemplateDelegate> = new Map();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private clustersService: ClustersService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.loadTemplates();
|
||||
}
|
||||
|
||||
private loadTemplates(): void {
|
||||
const templatesDir = path.join(__dirname, '..', '..', 'templates');
|
||||
const templateFiles = ['namespace', 'deployment', 'service', 'ingress', 'database', 'pvc', 'secret'];
|
||||
|
||||
for (const name of templateFiles) {
|
||||
const filePath = path.join(templatesDir, `${name}.yaml.hbs`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const template = fs.readFileSync(filePath, 'utf-8');
|
||||
this.templates.set(name, Handlebars.compile(template));
|
||||
this.logger.log(`Loaded template: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getK8sClient(clusterId?: string): Promise<{
|
||||
coreApi: k8s.CoreV1Api;
|
||||
appsApi: k8s.AppsV1Api;
|
||||
networkingApi: k8s.NetworkingV1Api;
|
||||
}> {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
|
||||
return {
|
||||
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||
};
|
||||
}
|
||||
|
||||
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> {
|
||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||
const domain = this.configService.get('platform.domain');
|
||||
|
||||
const context: ManifestContext = {
|
||||
appName: app.name,
|
||||
namespace: `user-${app.userId.split('-')[0]}`,
|
||||
image: imageUri,
|
||||
port: app.port,
|
||||
replicas: app.replicas,
|
||||
cpuRequest: app.cpuRequest,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryRequest: app.memoryRequest,
|
||||
memoryLimit: app.memoryLimit,
|
||||
envVars: app.envVars || {},
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
domain: domain,
|
||||
subdomain: app.subdomain || app.name,
|
||||
};
|
||||
|
||||
const manifests: Record<string, any> = {};
|
||||
|
||||
try {
|
||||
// 1. Ensure namespace exists
|
||||
await this.ensureNamespace(coreApi, context.namespace);
|
||||
|
||||
// 2. Create/Update secrets for env vars
|
||||
if (Object.keys(context.envVars).length > 0) {
|
||||
manifests.secret = await this.applySecret(coreApi, context);
|
||||
}
|
||||
|
||||
// 3. Deploy database if needed
|
||||
if (context.databaseType !== DatabaseType.NONE) {
|
||||
manifests.database = await this.deployDatabase(coreApi, appsApi, context);
|
||||
}
|
||||
|
||||
// 4. Create Deployment
|
||||
manifests.deployment = await this.applyDeployment(appsApi, context);
|
||||
|
||||
// 5. Create Service
|
||||
manifests.service = await this.applyService(coreApi, context);
|
||||
|
||||
// 6. Create Ingress
|
||||
manifests.ingress = await this.applyIngress(networkingApi, context);
|
||||
|
||||
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to deploy ${app.name}:`, error.body || error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
|
||||
try {
|
||||
await coreApi.readNamespace(namespace);
|
||||
} catch {
|
||||
await coreApi.createNamespace({
|
||||
metadata: { name: namespace },
|
||||
});
|
||||
this.logger.log(`Created namespace: ${namespace}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async applySecret(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
|
||||
const secretData: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(ctx.envVars)) {
|
||||
secretData[key] = Buffer.from(value).toString('base64');
|
||||
}
|
||||
|
||||
const secret = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: `${ctx.appName}-env`,
|
||||
namespace: ctx.namespace,
|
||||
},
|
||||
data: secretData,
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.replaceNamespacedSecret(`${ctx.appName}-env`, ctx.namespace, secret);
|
||||
} catch {
|
||||
await coreApi.createNamespacedSecret(ctx.namespace, secret);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
private async applyDeployment(appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<any> {
|
||||
const envFrom: any[] = [];
|
||||
if (Object.keys(ctx.envVars).length > 0) {
|
||||
envFrom.push({ secretRef: { name: `${ctx.appName}-env` } });
|
||||
}
|
||||
|
||||
// Add database connection env vars
|
||||
const extraEnv: any[] = [];
|
||||
if (ctx.databaseType === DatabaseType.POSTGRESQL) {
|
||||
extraEnv.push(
|
||||
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
|
||||
{ name: 'DB_PORT', value: '5432' },
|
||||
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
|
||||
{ name: 'DB_USER', value: 'appuser' },
|
||||
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||
);
|
||||
} else if (ctx.databaseType === DatabaseType.MYSQL) {
|
||||
extraEnv.push(
|
||||
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
|
||||
{ name: 'DB_PORT', value: '3306' },
|
||||
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
|
||||
{ name: 'DB_USER', value: 'appuser' },
|
||||
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||
);
|
||||
}
|
||||
|
||||
const deployment: k8s.V1Deployment = {
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: {
|
||||
name: ctx.appName,
|
||||
namespace: ctx.namespace,
|
||||
labels: { app: ctx.appName, runtime: ctx.runtime },
|
||||
},
|
||||
spec: {
|
||||
replicas: ctx.replicas,
|
||||
selector: { matchLabels: { app: ctx.appName } },
|
||||
template: {
|
||||
metadata: { labels: { app: ctx.appName, runtime: ctx.runtime } },
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: ctx.appName,
|
||||
image: ctx.image,
|
||||
ports: [{ containerPort: ctx.port }],
|
||||
envFrom,
|
||||
env: extraEnv,
|
||||
resources: {
|
||||
requests: { cpu: ctx.cpuRequest, memory: ctx.memoryRequest },
|
||||
limits: { cpu: ctx.cpuLimit, memory: ctx.memoryLimit },
|
||||
},
|
||||
readinessProbe: {
|
||||
httpGet: { path: '/health', port: ctx.port as any },
|
||||
initialDelaySeconds: 10,
|
||||
periodSeconds: 5,
|
||||
},
|
||||
livenessProbe: {
|
||||
httpGet: { path: '/health', port: ctx.port as any },
|
||||
initialDelaySeconds: 30,
|
||||
periodSeconds: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await appsApi.replaceNamespacedDeployment(ctx.appName, ctx.namespace, deployment);
|
||||
} catch {
|
||||
await appsApi.createNamespacedDeployment(ctx.namespace, deployment);
|
||||
}
|
||||
return deployment;
|
||||
}
|
||||
|
||||
private async applyService(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
|
||||
const service: k8s.V1Service = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: {
|
||||
name: ctx.appName,
|
||||
namespace: ctx.namespace,
|
||||
},
|
||||
spec: {
|
||||
selector: { app: ctx.appName },
|
||||
ports: [{ port: 80, targetPort: ctx.port as any, protocol: 'TCP' }],
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.replaceNamespacedService(ctx.appName, ctx.namespace, service);
|
||||
} catch {
|
||||
await coreApi.createNamespacedService(ctx.namespace, service);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise<any> {
|
||||
const ingress: k8s.V1Ingress = {
|
||||
apiVersion: 'networking.k8s.io/v1',
|
||||
kind: 'Ingress',
|
||||
metadata: {
|
||||
name: ctx.appName,
|
||||
namespace: ctx.namespace,
|
||||
annotations: {
|
||||
'kubernetes.io/ingress.class': 'nginx',
|
||||
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
rules: [
|
||||
{
|
||||
host: `${ctx.subdomain}.${ctx.domain}`,
|
||||
http: {
|
||||
paths: [
|
||||
{
|
||||
path: '/',
|
||||
pathType: 'Prefix',
|
||||
backend: {
|
||||
service: {
|
||||
name: ctx.appName,
|
||||
port: { number: 80 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
tls: [
|
||||
{
|
||||
hosts: [`${ctx.subdomain}.${ctx.domain}`],
|
||||
secretName: `${ctx.appName}-tls`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await networkingApi.replaceNamespacedIngress(ctx.appName, ctx.namespace, ingress);
|
||||
} catch {
|
||||
await networkingApi.createNamespacedIngress(ctx.namespace, ingress);
|
||||
}
|
||||
return ingress;
|
||||
}
|
||||
|
||||
private async deployDatabase(
|
||||
coreApi: k8s.CoreV1Api,
|
||||
appsApi: k8s.AppsV1Api,
|
||||
ctx: ManifestContext,
|
||||
): Promise<any> {
|
||||
const dbPassword = this.generatePassword();
|
||||
const dbName = `${ctx.appName}-db`;
|
||||
|
||||
// Create DB secret
|
||||
await this.createDbSecret(coreApi, ctx.namespace, ctx.appName, dbPassword);
|
||||
|
||||
// Create PVC for DB
|
||||
await this.createPVC(coreApi, ctx.namespace, dbName, '5Gi');
|
||||
|
||||
// Deploy database
|
||||
const isPostgres = ctx.databaseType === DatabaseType.POSTGRESQL;
|
||||
const image = isPostgres ? 'postgres:16-alpine' : 'mysql:8.0';
|
||||
const port = isPostgres ? 5432 : 3306;
|
||||
const envVars = isPostgres
|
||||
? [
|
||||
{ name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') },
|
||||
{ name: 'POSTGRES_USER', value: 'appuser' },
|
||||
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||
]
|
||||
: [
|
||||
{ name: 'MYSQL_DATABASE', value: ctx.appName.replace(/-/g, '_') },
|
||||
{ name: 'MYSQL_USER', value: 'appuser' },
|
||||
{ name: 'MYSQL_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||
{ name: 'MYSQL_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||
];
|
||||
|
||||
const dbDeployment: k8s.V1Deployment = {
|
||||
apiVersion: 'apps/v1',
|
||||
kind: 'Deployment',
|
||||
metadata: { name: dbName, namespace: ctx.namespace },
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: { matchLabels: { app: dbName } },
|
||||
template: {
|
||||
metadata: { labels: { app: dbName } },
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: dbName,
|
||||
image,
|
||||
ports: [{ containerPort: port }],
|
||||
env: envVars,
|
||||
volumeMounts: [{ name: 'db-storage', mountPath: isPostgres ? '/var/lib/postgresql/data' : '/var/lib/mysql' }],
|
||||
resources: {
|
||||
requests: { cpu: '100m', memory: '256Mi' },
|
||||
limits: { cpu: '500m', memory: '512Mi' },
|
||||
},
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{ name: 'db-storage', persistentVolumeClaim: { claimName: dbName } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await appsApi.replaceNamespacedDeployment(dbName, ctx.namespace, dbDeployment);
|
||||
} catch {
|
||||
await appsApi.createNamespacedDeployment(ctx.namespace, dbDeployment);
|
||||
}
|
||||
|
||||
// Create DB Service
|
||||
const dbService: k8s.V1Service = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: { name: dbName, namespace: ctx.namespace },
|
||||
spec: {
|
||||
selector: { app: dbName },
|
||||
ports: [{ port, targetPort: port as any, protocol: 'TCP' }],
|
||||
type: 'ClusterIP',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.replaceNamespacedService(dbName, ctx.namespace, dbService);
|
||||
} catch {
|
||||
await coreApi.createNamespacedService(ctx.namespace, dbService);
|
||||
}
|
||||
|
||||
return { deployment: dbDeployment, service: dbService };
|
||||
}
|
||||
|
||||
private async createDbSecret(
|
||||
coreApi: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
appName: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const secret = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
metadata: { name: `${appName}-db-secret`, namespace },
|
||||
data: { password: Buffer.from(password).toString('base64') },
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.replaceNamespacedSecret(`${appName}-db-secret`, namespace, secret);
|
||||
} catch {
|
||||
await coreApi.createNamespacedSecret(namespace, secret);
|
||||
}
|
||||
}
|
||||
|
||||
private async createPVC(
|
||||
coreApi: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
name: string,
|
||||
size: string,
|
||||
): Promise<void> {
|
||||
const pvc: k8s.V1PersistentVolumeClaim = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: { name, namespace },
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: size } },
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await coreApi.readNamespacedPersistentVolumeClaim(name, namespace);
|
||||
// PVC exists, don't recreate
|
||||
} catch {
|
||||
await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc);
|
||||
}
|
||||
}
|
||||
|
||||
async getPodLogs(app: Application): Promise<string> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
const pods = await coreApi.listNamespacedPod(
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`app=${app.name}`,
|
||||
);
|
||||
|
||||
if (pods.body.items.length === 0) {
|
||||
return 'No pods found for this application.';
|
||||
}
|
||||
|
||||
const podName = pods.body.items[0].metadata?.name;
|
||||
if (!podName) return 'Pod name not found.';
|
||||
|
||||
const logResponse = await coreApi.readNamespacedPodLog(
|
||||
podName,
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
200,
|
||||
);
|
||||
|
||||
return logResponse.body;
|
||||
}
|
||||
|
||||
async scaleDeployment(app: Application, replicas: number): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
await appsApi.patchNamespacedDeployment(
|
||||
app.name,
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
|
||||
);
|
||||
}
|
||||
|
||||
async restartDeployment(app: Application): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
await appsApi.patchNamespacedDeployment(
|
||||
app.name,
|
||||
namespace,
|
||||
{
|
||||
spec: {
|
||||
template: {
|
||||
metadata: {
|
||||
annotations: {
|
||||
'kubectl.kubernetes.io/restartedAt': new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteApplication(app: Application): Promise<void> {
|
||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
try {
|
||||
await appsApi.deleteNamespacedDeployment(app.name, namespace);
|
||||
await coreApi.deleteNamespacedService(app.name, namespace);
|
||||
await networkingApi.deleteNamespacedIngress(app.name, namespace);
|
||||
|
||||
// Delete DB resources if applicable
|
||||
if (app.databaseType !== DatabaseType.NONE) {
|
||||
const dbName = `${app.name}-db`;
|
||||
await appsApi.deleteNamespacedDeployment(dbName, namespace);
|
||||
await coreApi.deleteNamespacedService(dbName, namespace);
|
||||
await coreApi.deleteNamespacedPersistentVolumeClaim(dbName, namespace);
|
||||
await coreApi.deleteNamespacedSecret(`${app.name}-db-secret`, namespace);
|
||||
}
|
||||
|
||||
await coreApi.deleteNamespacedSecret(`${app.name}-env`, namespace);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Error cleaning up resources for ${app.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private generatePassword(length = 24): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
// Prevent Node.js from crashing on unhandled errors
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('⚠️ Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('⚠️ Uncaught Exception:', error);
|
||||
// Don't exit — let NestJS handle recovery
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
// Enable graceful shutdown hooks
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Security
|
||||
app.use(helmet());
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// API prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// Swagger
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('CloudHost PaaS API')
|
||||
.setDescription('Self-service PaaS platform API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 4000;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 CloudHost API running on http://localhost:${port}`);
|
||||
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AppModule } from './app.module';
|
||||
import { UsersService } from './users/users.service';
|
||||
import { UserRole } from './common/enums';
|
||||
|
||||
/**
|
||||
* Seed script — creates the initial super admin user.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register src/seed.ts
|
||||
*
|
||||
* Or via npm script:
|
||||
* npm run seed
|
||||
*
|
||||
* Environment variables (or defaults):
|
||||
* ADMIN_EMAIL=admin@cloudhost.local
|
||||
* ADMIN_PASSWORD=Admin123!
|
||||
*/
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
const usersService = app.get(UsersService);
|
||||
|
||||
const email = process.env.ADMIN_EMAIL || 'admin@cloudhost.local';
|
||||
const password = process.env.ADMIN_PASSWORD || 'Admin123!';
|
||||
|
||||
const existing = await usersService.findByEmail(email);
|
||||
if (existing) {
|
||||
console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`);
|
||||
if (existing.role !== UserRole.ADMIN) {
|
||||
await usersService.update(existing.id, { role: UserRole.ADMIN });
|
||||
console.log(`✅ Promoted ${email} to admin`);
|
||||
}
|
||||
} else {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
await usersService.create({
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName: 'Super',
|
||||
lastName: 'Admin',
|
||||
role: UserRole.ADMIN,
|
||||
});
|
||||
console.log(`✅ Admin user created: ${email}`);
|
||||
}
|
||||
|
||||
console.log(`\n📋 Admin credentials:`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}`);
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('❌ Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserRole } from '../../common/enums';
|
||||
import { Application } from '../../applications/entities/application.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@Column()
|
||||
firstName: string;
|
||||
|
||||
@Column()
|
||||
lastName: string;
|
||||
|
||||
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
|
||||
role: UserRole;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
namespace: string; // K8s namespace assigned to user
|
||||
|
||||
@OneToMany(() => Application, (app: Application) => app.user)
|
||||
applications: Application[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@ApiTags('Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
async getProfile(@Request() req: any) {
|
||||
const user = await this.usersService.findById(req.user.id);
|
||||
if (user) {
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all users (Admin only)' })
|
||||
async findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Deactivate a user (Admin only)' })
|
||||
async deactivate(@Param('id') id: string) {
|
||||
await this.usersService.deactivate(id);
|
||||
return { message: 'User deactivated' };
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Activate a user (Admin only)' })
|
||||
async activate(@Param('id') id: string) {
|
||||
await this.usersService.activate(id);
|
||||
return { message: 'User activated' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async create(data: Partial<User>): Promise<User> {
|
||||
const user = this.usersRepository.create(data);
|
||||
// Assign a unique namespace based on user ID
|
||||
const saved = await this.usersRepository.save(user);
|
||||
saved.namespace = `user-${saved.id.split('-')[0]}`;
|
||||
return this.usersRepository.save(saved);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.usersRepository.find({
|
||||
select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'namespace', 'createdAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<User>): Promise<User> {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
Object.assign(user, data);
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { isActive: false });
|
||||
}
|
||||
|
||||
async activate(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { isActive: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
{{#if isPostgres}}
|
||||
# --- PostgreSQL Deployment ---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: "{{appName}}-db"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
app: "{{appName}}-db"
|
||||
managed-by: cloudhost
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: "{{appName}}-db"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: "{{appName}}-db"
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: "{{dbName}}"
|
||||
- name: POSTGRES_USER
|
||||
value: "appuser"
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{appName}}-db-secret"
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: db-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "256Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
volumes:
|
||||
- name: db-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: "{{appName}}-db"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: "{{appName}}-db"
|
||||
namespace: "{{namespace}}"
|
||||
spec:
|
||||
selector:
|
||||
app: "{{appName}}-db"
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
type: ClusterIP
|
||||
{{/if}}
|
||||
|
||||
{{#if isMysql}}
|
||||
# --- MySQL Deployment ---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: "{{appName}}-db"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
app: "{{appName}}-db"
|
||||
managed-by: cloudhost
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: "{{appName}}-db"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: "{{appName}}-db"
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: mysql:8.0
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
env:
|
||||
- name: MYSQL_DATABASE
|
||||
value: "{{dbName}}"
|
||||
- name: MYSQL_USER
|
||||
value: "appuser"
|
||||
- name: MYSQL_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{appName}}-db-secret"
|
||||
key: password
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{appName}}-db-secret"
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: db-storage
|
||||
mountPath: /var/lib/mysql
|
||||
resources:
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "256Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
volumes:
|
||||
- name: db-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: "{{appName}}-db"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: "{{appName}}-db"
|
||||
namespace: "{{namespace}}"
|
||||
spec:
|
||||
selector:
|
||||
app: "{{appName}}-db"
|
||||
ports:
|
||||
- port: 3306
|
||||
targetPort: 3306
|
||||
type: ClusterIP
|
||||
{{/if}}
|
||||
@@ -0,0 +1,65 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: "{{appName}}"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
app: "{{appName}}"
|
||||
runtime: "{{runtime}}"
|
||||
managed-by: cloudhost
|
||||
spec:
|
||||
replicas: {{replicas}}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: "{{appName}}"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: "{{appName}}"
|
||||
runtime: "{{runtime}}"
|
||||
spec:
|
||||
containers:
|
||||
- name: "{{appName}}"
|
||||
image: "{{image}}"
|
||||
ports:
|
||||
- containerPort: {{port}}
|
||||
{{#if hasEnvVars}}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{appName}}-env"
|
||||
{{/if}}
|
||||
{{#if hasDatabase}}
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: "{{appName}}-db"
|
||||
- name: DB_PORT
|
||||
value: "{{dbPort}}"
|
||||
- name: DB_NAME
|
||||
value: "{{dbName}}"
|
||||
- name: DB_USER
|
||||
value: "appuser"
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{appName}}-db-secret"
|
||||
key: password
|
||||
{{/if}}
|
||||
resources:
|
||||
requests:
|
||||
cpu: "{{cpuRequest}}"
|
||||
memory: "{{memoryRequest}}"
|
||||
limits:
|
||||
cpu: "{{cpuLimit}}"
|
||||
memory: "{{memoryLimit}}"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: {{port}}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: {{port}}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
@@ -0,0 +1,28 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: "{{appName}}"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
app: "{{appName}}"
|
||||
managed-by: cloudhost
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- "{{subdomain}}.{{domain}}"
|
||||
secretName: "{{appName}}-tls"
|
||||
rules:
|
||||
- host: "{{subdomain}}.{{domain}}"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: "{{appName}}"
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: "{{namespace}}"
|
||||
labels:
|
||||
managed-by: cloudhost
|
||||
user: "{{userId}}"
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: "{{name}}"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
managed-by: cloudhost
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: "{{size}}"
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: "{{name}}"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
managed-by: cloudhost
|
||||
type: Opaque
|
||||
data:
|
||||
{{#each data}}
|
||||
{{@key}}: "{{this}}"
|
||||
{{/each}}
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: "{{appName}}"
|
||||
namespace: "{{namespace}}"
|
||||
labels:
|
||||
app: "{{appName}}"
|
||||
managed-by: cloudhost
|
||||
spec:
|
||||
selector:
|
||||
app: "{{appName}}"
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: {{port}}
|
||||
protocol: TCP
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user