22359be40e
Close billing, tenancy, migration, build, and CI/CD gaps identified in the audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with base schema, stateful service stability, safer Dockerfiles/git builds, and platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off). Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
12 KiB
TypeScript
337 lines
12 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 {
|
|
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<Application>,
|
|
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<string> {
|
|
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<Application> {
|
|
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<Application[]> {
|
|
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<Application[]> {
|
|
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<Application> {
|
|
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<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 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<Application> {
|
|
const app = await this.findOne(id);
|
|
app.latestImageTag = imageTag;
|
|
return this.appsRepository.save(app);
|
|
}
|
|
|
|
async updateClusterAssignment(id: string, clusterId: string, poolId?: string): Promise<Application> {
|
|
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<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 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<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;
|
|
}
|
|
}
|