This commit is contained in:
keyhan
2026-04-05 15:22:01 +03:30
commit 33be1649c4
82 changed files with 23956 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log
+33
View File
@@ -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
+32
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"tsConfigPath": "tsconfig.json"
},
"sourceRoot": "src",
"collection": "@nestjs/schematics",
"entryFile": "main"
}
+11419
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -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"
}
}
+61
View File
@@ -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;
}
+38
View File
@@ -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);
}
}
+27
View File
@@ -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 {}
+100
View File
@@ -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 };
}
}
+12
View File
@@ -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;
}
+26
View File
@@ -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,
};
}
}
+17
View File
@@ -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 {}
+42
View File
@@ -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;
}
}
}
+425
View File
@@ -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' };
}
}
+13
View File
@@ -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 {}
+142
View File
@@ -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);
}
}
+94
View File
@@ -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);
+41
View File
@@ -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',
}
+23
View File
@@ -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);
}
}
+41
View File
@@ -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;
}
}
+60
View File
@@ -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();
+56
View File
@@ -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);
});
+46
View File
@@ -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;
}
+57
View File
@@ -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' };
}
}
+13
View File
@@ -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 {}
+52
View File
@@ -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 });
}
}
+132
View File
@@ -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}}
+65
View File
@@ -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
+28
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: "{{namespace}}"
labels:
managed-by: cloudhost
user: "{{userId}}"
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "{{name}}"
namespace: "{{namespace}}"
labels:
managed-by: cloudhost
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: "{{size}}"
+12
View File
@@ -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}}
+16
View File
@@ -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
+26
View File
@@ -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/**/*"]
}