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
@@ -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;
}