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 * as crypto from 'crypto'; import { Application } from './entities/application.entity'; import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto'; import { ClustersService } from '../clusters/clusters.service'; import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums'; import { ensureAppUrlEnv } from './app-url.util'; @Injectable() export class ApplicationsService { private readonly logger = new Logger(ApplicationsService.name); constructor( @InjectRepository(Application) private appsRepository: Repository, private clustersService: ClustersService, private configService: ConfigService, ) {} async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise { // Only admin/technical users can manually select cluster or pool // Regular users always get the default cluster assignment if (userRole !== UserRole.ADMIN && userRole !== UserRole.TECHNICAL) { if (dto.clusterId || dto.poolId) { this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection — ignoring`); } dto.clusterId = undefined; dto.poolId = undefined; } // Cluster assignment: 3 modes // 1. Manual: specific clusterId provided // 2. Pool-based LB: poolId provided → pick from pool using pool's strategy // 3. Default fallback: no clusterId/poolId → use default cluster let clusterId = dto.clusterId; let poolId = dto.poolId; if (!clusterId) { if (poolId) { // Mode 2: Pool-based load balancing try { const optimal = await this.clustersService.getOptimalClusterFromPool(poolId); clusterId = optimal.id; this.logger.log(`Pool-assigned cluster "${optimal.name}" to app "${dto.name}" (pool LB)`); } catch (err: any) { this.logger.warn(`Pool assignment failed for app "${dto.name}": ${err.message}`); poolId = undefined; // Clear invalid pool } } // Mode 3: Default fallback (no manual cluster, no pool, or pool failed) if (!clusterId) { try { const defaultCluster = await this.clustersService.getDefault(); clusterId = defaultCluster.id; this.logger.log(`Default-assigned cluster "${defaultCluster.name}" to app "${dto.name}"`); } catch { this.logger.warn('No cluster available — app will be created without cluster assignment'); } } } else { this.logger.log(`Manual cluster assignment for app "${dto.name}" → cluster ${clusterId}`); } // Generate database credentials if a database is requested let dbUsername: string | undefined; let dbPassword: string | undefined; if (dto.databaseType && dto.databaseType !== DatabaseType.NONE) { dbUsername = dto.dbUsername?.trim() || 'appuser'; dbPassword = dto.dbPassword?.trim() || crypto.randomBytes(16).toString('hex'); this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`); } const customDomain = dto.customDomain?.toLowerCase().trim() || undefined; const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(dto.runtime) ? 80 : 3000; const subdomain = `${dto.name}-${userId.split('-')[0]}`; const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; const app = this.appsRepository.create({ ...dto, userId, clusterId, poolId, dbUsername, dbPassword, port: dto.port ?? defaultPort, subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, envVars: ensureAppUrlEnv( { name: dto.name, runtime: dto.runtime, subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, envVars: dto.envVars ?? {}, }, platformDomain, ), }); return this.appsRepository.save(app); } async findAllByUser(userId: string): Promise { return this.appsRepository.find({ where: { userId }, relations: ['deployments'], order: { createdAt: 'DESC' }, }); } async findAll(search?: string): Promise { const qb = this.appsRepository .createQueryBuilder('app') .leftJoinAndSelect('app.user', 'user') .leftJoinAndSelect('app.deployments', 'deployments') .orderBy('app.createdAt', 'DESC'); if (search && search.trim()) { const s = `%${search.trim()}%`; qb.where( '(user.firstName ILIKE :s OR user.lastName ILIKE :s OR user.email ILIKE :s OR CAST(app.userId AS TEXT) ILIKE :s OR app.name ILIKE :s)', { s }, ); } return qb.getMany(); } async findOne(id: string, userId?: string): Promise { 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 { const app = await this.findOne(id, userId); Object.assign(app, dto); return this.appsRepository.save(app); } async delete(id: string, userId: string): Promise { const app = await this.findOne(id, userId); // Delete uploaded files if (app.codePath) { try { const uploadDir = this.configService.get('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 { const app = await this.findOne(id); app.latestImageTag = imageTag; return this.appsRepository.save(app); } async saveSuspendedReplicas( id: string, snapshot: Record, ): Promise { const app = await this.findOne(id); app.suspendedReplicas = snapshot; app.suspendedAt = new Date(); if (snapshot[app.name] !== undefined) { app.replicas = snapshot[app.name]; } return this.appsRepository.save(app); } async clearSuspendedReplicas(id: string): Promise { const app = await this.findOne(id); app.suspendedReplicas = undefined; return this.appsRepository.save(app); } async uploadCode(id: string, userId: string, file: Express.Multer.File): Promise { if (!file) { throw new BadRequestException('No file uploaded'); } const app = await this.findOne(id, userId); const uploadDir = this.configService.get('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; } async uploadDbDump(id: string, userId: string, file: Express.Multer.File): Promise { if (!file) { throw new BadRequestException('No file uploaded'); } const app = await this.findOne(id, userId); const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; const appDir = path.join(uploadDir, app.userId, app.id); // Ensure directory exists fs.mkdirSync(appDir, { recursive: true }); // Save the SQL dump file const dumpPath = path.join(appDir, 'dump.sql'); fs.writeFileSync(dumpPath, file.buffer); // Update app with dump path app.dbDumpPath = dumpPath; const saved = await this.appsRepository.save(app); this.logger.log(`Uploaded DB dump for ${app.name} → ${dumpPath} (${(file.size / 1024).toFixed(1)} KB)`); return saved; } }