Files
cloud-host/backend/src/applications/applications.service.ts
T
keyhan 35dd771f63 Add unified logs platform with Helm-managed central Elasticsearch.
Deploy cloudhost-logging on cluster registration, ship app and optional service logs to ES with owner isolation, and fix Kibana 8.12 auth via kibana_system.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 15:56:33 +03:30

259 lines
8.9 KiB
TypeScript

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<Application>,
private clustersService: ClustersService,
private configService: ConfigService,
) {}
async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> {
// 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<Application[]> {
return this.appsRepository.find({
where: { userId },
relations: ['deployments'],
order: { createdAt: 'DESC' },
});
}
async findAll(search?: string): Promise<Application[]> {
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<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 saveSuspendedReplicas(
id: string,
snapshot: Record<string, number>,
): Promise<Application> {
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<Application> {
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<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;
}
async uploadDbDump(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 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;
}
}