init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user