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 { DatabaseType, CustomDomainStatus, AppRuntime, ProductType, isManagedProductType, } from '../common/enums'; import { ensureAppUrlEnv } from './app-url.util'; import { normalizeCreateApplicationDto } from './managed-service.util'; import { assertRuntimeMatch, detectRuntimeFromArchive, } from '../build/runtime-detector'; import { SourceStorageService } from '../storage/source-storage.service'; import { userIdSlug } from '../kubernetes/k8s-workload.util'; import * as os from 'os'; @Injectable() export class ApplicationsService { private readonly logger = new Logger(ApplicationsService.name); constructor( @InjectRepository(Application) private appsRepository: Repository, private clustersService: ClustersService, private configService: ConfigService, private sourceStorage: SourceStorageService, ) {} private toDnsLabel(value: string): string { return (value || '') .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 63); } private async generateRandomSubdomain(baseLabel: string): Promise { const base = this.toDnsLabel(baseLabel) || 'app'; const suffixLenBytes = 5; // 10 hex chars const suffixLen = suffixLenBytes * 2; for (let attempt = 0; attempt < 10; attempt++) { const suffix = crypto.randomBytes(suffixLenBytes).toString('hex'); // [0-9a-f] const maxBaseLen = 63 - 1 - suffixLen; // base + '-' + suffix const truncatedBase = base.slice(0, Math.max(1, maxBaseLen)).replace(/-+$/g, ''); const candidate = `${truncatedBase}-${suffix}`; const exists = await this.appsRepository.findOne({ where: { subdomain: candidate } }); if (!exists) return candidate; } throw new BadRequestException('Failed to generate a unique subdomain. Please try again.'); } async create(userId: string, dto: CreateApplicationDto): Promise { dto = normalizeCreateApplicationDto(dto); const productType = dto.productType ?? ProductType.APPLICATION; // WordPress only runs on MySQL/MariaDB — reject PostgreSQL/Mongo/none up // front instead of failing at runtime inside the WordPress container. if (dto.runtime === AppRuntime.WORDPRESS) { if (!dto.databaseType || dto.databaseType === DatabaseType.NONE) { dto.databaseType = DatabaseType.MYSQL; } else if (![DatabaseType.MYSQL, DatabaseType.MARIADB].includes(dto.databaseType)) { throw new BadRequestException( `WordPress requires a MySQL or MariaDB database — "${dto.databaseType}" is not supported.`, ); } } // Placement is always decided automatically by the allocator. const allocation = await this.clustersService.selectClusterForApplication(dto, userId); const clusterId = allocation.cluster.id; const poolId = allocation.pool?.id; const allocationLogId = allocation.allocationLogId; if (!clusterId) { throw new BadRequestException('No eligible cluster available for this application'); } // 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 = isManagedProductType(productType) ? undefined : dto.customDomain?.toLowerCase().trim() || undefined; const runtime = dto.runtime ?? AppRuntime.NODEJS; const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(runtime) ? 80 : 3000; const baseLabel = dto.name; const subdomain = customDomain ? `${this.toDnsLabel(baseLabel)}-${this.toDnsLabel(userIdSlug(userId).slice(0, 12))}` : await this.generateRandomSubdomain(baseLabel); const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; const app = this.appsRepository.create({ ...dto, productType, runtime, userId, clusterId, poolId, dbUsername, dbPassword, replicas: isManagedProductType(productType) ? 0 : (dto.replicas ?? 1), port: dto.port ?? defaultPort, subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, envVars: isManagedProductType(productType) ? (dto.envVars ?? {}) : ensureAppUrlEnv( { name: dto.name, runtime, subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, envVars: dto.envVars ?? {}, }, platformDomain, ), }); const saved = await this.appsRepository.save(app); if (allocationLogId) { await this.clustersService.attachAllocationToApplication(allocationLogId, saved.id); } return saved; } async findAllByUser( userId: string, options?: { productType?: 'application' | 'managed' }, ): Promise { const qb = this.appsRepository .createQueryBuilder('app') .leftJoinAndSelect('app.deployments', 'deployments') .where('app.userId = :userId', { userId }) .orderBy('app.createdAt', 'DESC') // Ensure deployments[0] is the most recent so the UI shows the latest status. .addOrderBy('deployments.createdAt', 'DESC'); if (options?.productType === 'application') { qb.andWhere( '(app.productType = :applicationType OR app.productType IS NULL)', { applicationType: ProductType.APPLICATION }, ); } else if (options?.productType === 'managed') { qb.andWhere('app.productType IN (:...managedTypes)', { managedTypes: [ ProductType.MANAGED_DATABASE, ProductType.MANAGED_REDIS, ProductType.MANAGED_RABBITMQ, ], }); } return qb.getMany(); } async findAll(search?: string): Promise { const qb = this.appsRepository .createQueryBuilder('app') .leftJoinAndSelect('app.user', 'user') .leftJoinAndSelect('app.deployments', 'deployments') .orderBy('app.createdAt', 'DESC') // Ensure deployments[0] is the most recent so the UI shows the latest status. .addOrderBy('deployments.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: true }, }); 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 source files if (app.codePath) { try { await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath); } catch (e: any) { this.logger.warn(`Failed to delete source 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 updateClusterAssignment(id: string, clusterId: string, poolId?: string): Promise { const app = await this.findOne(id); app.clusterId = clusterId; app.poolId = poolId || app.poolId; 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 tempPath = path.join(os.tmpdir(), `upload-${app.id}-${Date.now()}.zip`); fs.writeFileSync(tempPath, file.buffer); try { const detected = await detectRuntimeFromArchive(tempPath); assertRuntimeMatch(app.runtime, detected); const storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer); app.codePath = storedPath; const saved = await this.appsRepository.save(app); if (detected.confidence === 'low') { Object.assign(saved, { runtimeWarning: 'Could not determine the project type from the archive with high confidence. Build may fail if the selected runtime is wrong.', }); } this.logger.log(`Uploaded code for ${app.name} → ${storedPath} (${(file.size / 1024).toFixed(1)} KB)`); return saved; } catch (err) { try { await this.sourceStorage.deleteSource(app.userId, app.id); } catch { // ignore rollback errors } throw err; } finally { if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } } } 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; } }