import { Injectable, NotFoundException, Logger, BadRequestException, Inject, forwardRef, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { Repository, DataSource, In } from 'typeorm'; import * as k8s from '@kubernetes/client-node'; import * as crypto from 'crypto'; import { Cluster, ClusterHealthStatus } from './entities/cluster.entity'; import { ClusterPool, PoolStrategy } from './entities/cluster-pool.entity'; import { ClusterHealth } from './entities/cluster-health.entity'; import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity'; import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto'; import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto'; import { ClusterStatus } from '../common/enums'; import { RegistryService } from '../kubernetes/registry.service'; import { CreateApplicationDto } from '../applications/dto/application.dto'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; @Injectable() export class ClustersService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ClustersService.name); private roundRobinIndex = 0; private poolRoundRobinIndices = new Map(); private weightedRoundRobinState = new Map(); private clusterHealthCache = new Map(); private healthCheckTimer?: NodeJS.Timeout; private readonly cacheTtlMs = Number(process.env.CLUSTER_HEALTH_CACHE_TTL_MS || 60_000); private readonly overloadedCpuThreshold = Number(process.env.CLUSTER_OVERLOADED_CPU_THRESHOLD || 0.85); private readonly overloadedMemoryThreshold = Number(process.env.CLUSTER_OVERLOADED_MEMORY_THRESHOLD || 0.85); private readonly overloadedPodThreshold = Number(process.env.CLUSTER_OVERLOADED_POD_THRESHOLD || 0.85); constructor( @InjectRepository(Cluster) private clustersRepository: Repository, @InjectRepository(ClusterPool) private poolsRepository: Repository, @InjectRepository(ClusterHealth) private healthRepository: Repository, @InjectRepository(ClusterAllocationLog) private allocationLogsRepository: Repository, private dataSource: DataSource, private configService: ConfigService, @Inject(forwardRef(() => RegistryService)) private registryService: RegistryService, ) {} onModuleInit(): void { const intervalMs = Number(process.env.CLUSTER_HEALTH_INTERVAL_MS || 120_000); this.healthCheckTimer = setInterval(() => { this.refreshAllClusterHealth().catch((err) => { this.logger.warn(`Periodic cluster health refresh failed: ${err.message}`); }); }, intervalMs); this.healthCheckTimer.unref?.(); } onModuleDestroy(): void { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); } } /** * Test connection to a Kubernetes cluster using its kubeconfig. * Calls the /version endpoint to verify the cluster is reachable. */ async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> { try { const decryptedKubeconfig = this.decryptKubeconfig(kubeconfig); registerKubeconfigNoProxy(decryptedKubeconfig); const kc = new k8s.KubeConfig(); kc.loadFromString(decryptedKubeconfig); const versionApi = kc.makeApiClient(k8s.VersionApi); const result = await versionApi.getCode(); const info = result; this.logger.log(`Cluster connection OK: Kubernetes ${info.gitVersion}`); return { connected: true, version: info.gitVersion, }; } catch (err: any) { const message = err?.body?.message || err?.message || 'Unknown connection error'; this.logger.warn(`Cluster connection failed: ${message}`); return { connected: false, error: message, }; } } async create(dto: CreateClusterDto): Promise { // Validate kubeconfig by testing actual connection const connectionTest = await this.testConnection(dto.kubeconfig); if (!connectionTest.connected) { throw new BadRequestException(`Cannot connect to Kubernetes cluster: ${connectionTest.error}`); } if (dto.isDefault === true) { const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true }, }); for (const c of existingDefaults) { c.isDefault = false; await this.clustersRepository.save(c); } } const cluster = this.clustersRepository.create({ ...dto, kubeconfig: this.encryptKubeconfig(dto.kubeconfig), status: ClusterStatus.ACTIVE, // Connection verified — mark active healthStatus: 'healthy', lastHealthCheckedAt: new Date(), healthMessage: connectionTest.version ? `Kubernetes ${connectionTest.version}` : 'Connection verified', }); const saved = await this.clustersRepository.save(cluster); const usableCluster = this.withDecryptedKubeconfig(saved); this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`); // Bootstrap the cluster with build infrastructure (namespace, registry, SA, etc.) this.bootstrapCluster(usableCluster.kubeconfig).catch((err) => { this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`); }); // Infrastructure tools (central logging, cert-manager, ClusterIssuer, …) are no // longer auto-installed. Install them on demand via Cluster → Tools Management. this.recordHealthSnapshot(saved, { status: 'healthy', message: connectionTest.version ? `Kubernetes ${connectionTest.version}` : 'Connection verified', }).catch((err) => this.logger.warn(`Failed to record cluster health: ${err.message}`)); return usableCluster; } async findAll(): Promise { return this.clustersRepository.find({ select: { id: true, name: true, description: true, status: true, apiServer: true, region: true, provider: true, isDefault: true, weight: true, tags: true, healthStatus: true, lastHealthCheckedAt: true, healthMessage: true, availableResources: true, createdAt: true, }, order: { createdAt: 'DESC' }, }); } async findOne(id: string): Promise { const cluster = await this.clustersRepository.findOne({ where: { id } }); if (!cluster) { throw new NotFoundException('Cluster not found'); } return this.withDecryptedKubeconfig(cluster); } async getDefault(): Promise { // Prefer active default cluster; fall back to any active cluster let cluster = await this.clustersRepository.findOne({ where: { isDefault: true, status: ClusterStatus.ACTIVE }, }); if (!cluster) { // Fallback: pick any active cluster and promote it to default cluster = await this.clustersRepository.findOne({ where: { status: ClusterStatus.ACTIVE }, }); if (cluster) { cluster.isDefault = true; await this.clustersRepository.save(cluster); this.logger.warn(`No active default cluster — promoted "${cluster.name}" to default`); } } if (!cluster) { throw new NotFoundException('No active cluster available'); } return this.withDecryptedKubeconfig(cluster); } async update(id: string, dto: UpdateClusterDto): Promise { const cluster = await this.findOne(id); // If kubeconfig is being updated, re-test connection if (dto.kubeconfig) { const connectionTest = await this.testConnection(dto.kubeconfig); if (!connectionTest.connected) { throw new BadRequestException(`Cannot connect to Kubernetes cluster: ${connectionTest.error}`); } dto.status = ClusterStatus.ACTIVE; dto.kubeconfig = this.encryptKubeconfig(dto.kubeconfig); this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`); // Re-bootstrap build infrastructure on the new/updated cluster this.bootstrapCluster(this.decryptKubeconfig(dto.kubeconfig)).catch((err: any) => { this.logger.error(`Failed to bootstrap cluster "${cluster.name}": ${err.message}`); }); } if (dto.isDefault === true) { const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true }, }); for (const c of existingDefaults) { if (c.id !== id) { c.isDefault = false; await this.clustersRepository.save(c); } } } Object.assign(cluster, dto); cluster.kubeconfig = this.encryptKubeconfig(cluster.kubeconfig); const saved = await this.clustersRepository.save(cluster); return this.withDecryptedKubeconfig(saved); } /** * Manually test connectivity to an existing cluster. * Updates status to active/inactive based on result. */ async testClusterById(id: string): Promise<{ connected: boolean; version?: string; error?: string }> { const cluster = await this.findOne(id); const result = await this.testConnection(cluster.kubeconfig); cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE; cluster.healthStatus = result.connected ? 'healthy' : 'unhealthy'; cluster.lastHealthCheckedAt = new Date(); cluster.healthMessage = result.connected ? (result.version ? `Kubernetes ${result.version}` : 'Connection verified') : result.error || 'Connection failed'; cluster.kubeconfig = this.encryptKubeconfig(cluster.kubeconfig); await this.clustersRepository.save(cluster); await this.recordHealthSnapshot(cluster, { status: cluster.healthStatus, message: cluster.healthMessage, }); this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`); return result; } /** * Public cluster list (no sensitive data) — for users to select a cluster. */ async findAllPublic(): Promise[]> { return this.clustersRepository.find({ select: { id: true, name: true, region: true, provider: true, isDefault: true, status: true, }, where: { status: ClusterStatus.ACTIVE }, order: { isDefault: 'DESC', name: 'ASC' }, }); } async selectClusterForApplication( dto: CreateApplicationDto & { poolId?: string }, userId: string, options: { excludeClusterIds?: string[]; applicationId?: string; reason?: string; } = {}, ): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string; }> { const estimatedRequest = this.estimateApplicationRequest(dto); let pool = dto.poolId ? await this.poolsRepository.findOne({ where: { id: dto.poolId, isActive: true }, }) : null; if (dto.poolId && !pool) { throw new BadRequestException('Selected cluster pool is not active or does not exist'); } if (!pool) { pool = (await this.poolsRepository.findOne({ where: { isActive: true, isDefault: true }, order: { priority: 'ASC', createdAt: 'ASC' }, })) || (await this.poolsRepository.findOne({ where: { isActive: true }, order: { priority: 'ASC', createdAt: 'ASC' }, })); } const candidates = await this.getCachedHealthyClusters(pool || undefined, options.excludeClusterIds || []); const clusterIds = candidates.map((cluster) => cluster.id); const appCounts = await this.getAppCounts(clusterIds); const reservations = await this.getInFlightReservations(clusterIds, options.applicationId); const candidateScores: Record[] = []; const rejectionReasons: Record[] = []; for (const cluster of candidates) { const reserved = reservations.get(cluster.id) || { cpuMillicores: 0, memoryMi: 0, pods: 0, }; const rejection = this.getClusterRejectionReason(cluster, estimatedRequest, reserved); if (rejection) { rejectionReasons.push({ clusterId: cluster.id, clusterName: cluster.name, reason: rejection, }); continue; } const strategy = this.resolveStrategy(pool?.strategy); const appCount = appCounts.get(cluster.id) || 0; const score = this.scoreCluster(cluster, estimatedRequest, appCount, strategy, (dto as any).region, reserved); const resourceMetrics = this.getResourceMetrics(cluster, estimatedRequest, appCount, reserved); candidateScores.push({ clusterId: cluster.id, clusterName: cluster.name, score, weight: cluster.weight || 1, appCount, healthStatus: cluster.healthStatus, utilization: resourceMetrics.utilization, availableResources: cluster.availableResources || null, }); } if (candidateScores.length === 0) { const log = await this.allocationLogsRepository.save( this.allocationLogsRepository.create({ userId, poolId: pool?.id, applicationId: options.applicationId, selectedClusterId: null, strategy: this.resolveStrategy(pool?.strategy), estimatedRequest, candidateScores, rejectionReasons, status: 'failed', message: options.reason || 'No active healthy cluster has enough estimated capacity', }), ); throw new BadRequestException(`No active healthy cluster has enough capacity for this application (allocation log: ${log.id})`); } candidateScores.sort((a, b) => b.score - a.score); const selected = await this.findOne(candidateScores[0].clusterId); const log = await this.allocationLogsRepository.save( this.allocationLogsRepository.create({ userId, poolId: pool?.id, applicationId: options.applicationId, selectedClusterId: selected.id, strategy: this.resolveStrategy(pool?.strategy), estimatedRequest, candidateScores, rejectionReasons, status: 'success', message: options.reason ? `${options.reason}: selected ${selected.name}` : `Selected ${selected.name}`, }), ); this.logger.log(`Allocator selected cluster "${selected.name}" for user ${userId} (score ${candidateScores[0].score.toFixed(2)})`); return { cluster: selected, pool: pool || undefined, allocationLogId: log.id, }; } async attachAllocationToApplication(allocationLogId: string, applicationId: string): Promise { await this.allocationLogsRepository.update({ id: allocationLogId }, { applicationId }); } async listAllocationLogs(limit = 100): Promise { return this.allocationLogsRepository.find({ relations: { selectedCluster: true, pool: true }, order: { createdAt: 'DESC' }, take: limit, }); } async markAllocationFailure(applicationId: string, clusterId: string | undefined, message: string): Promise { if (clusterId) { // A single deployment failure (quota, image pull, app bug, transient // scheduling pressure) does NOT mean the cluster is unhealthy — flipping // its healthStatus to 'degraded' would poison it for every other app and, // when there's only one cluster, cause "no healthy cluster has capacity" // for the next deployment. The fallback loop already excludes this cluster // for *this* app via excludeClusterIds, so here we only invalidate the // cached capacity snapshot to force a fresh read on the next allocation. this.clusterHealthCache.delete(clusterId); } const latest = await this.allocationLogsRepository.findOne({ where: { applicationId, ...(clusterId ? { selectedClusterId: clusterId } : {}), }, order: { createdAt: 'DESC' }, }); if (latest) { await this.allocationLogsRepository.update(latest.id, { status: 'failed', message, }); } } async chooseFallbackClusterForApplication( app: CreateApplicationDto & { id: string; userId: string; clusterId?: string; poolId?: string; }, failedClusterIds: string[], reason: string, ): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string; }> { return this.selectClusterForApplication(app, app.userId, { excludeClusterIds: failedClusterIds, applicationId: app.id, reason, }); } /** * Get the optimal cluster using load-balancing strategy. * Strategy: 'least-apps' — picks the active cluster with fewest deployed applications. * Falls back to default cluster if only one active cluster exists. */ async getOptimalCluster(strategy: 'least-apps' | 'round-robin' = 'least-apps'): Promise { const activeClusters = await this.clustersRepository.find({ where: { status: ClusterStatus.ACTIVE }, }); if (activeClusters.length === 0) { throw new NotFoundException('No active clusters available'); } if (activeClusters.length === 1) { return activeClusters[0]; } if (strategy === 'round-robin') { const cluster = activeClusters[this.roundRobinIndex % activeClusters.length]; this.roundRobinIndex++; this.logger.log(`Round-robin selected cluster "${cluster.name}" (index: ${this.roundRobinIndex - 1})`); return cluster; } // least-apps: count applications per cluster const appCounts: { clusterId: string; count: string }[] = await this.dataSource.query(` SELECT "clusterId", COUNT(*) as count FROM applications WHERE "clusterId" IS NOT NULL GROUP BY "clusterId" `); const countMap = new Map(); for (const row of appCounts) { countMap.set(row.clusterId, parseInt(row.count, 10)); } // Sort by app count ascending (least apps first) activeClusters.sort((a, b) => { const countA = countMap.get(a.id) || 0; const countB = countMap.get(b.id) || 0; return countA - countB; }); const selected = activeClusters[0]; const selectedCount = countMap.get(selected.id) || 0; this.logger.log(`Least-apps selected cluster "${selected.name}" (${selectedCount} apps)`); return selected; } async delete(id: string): Promise { const cluster = await this.findOne(id); const wasDefault = cluster.isDefault; // Reassign applications that were on this cluster to another active cluster try { const replacement = await this.clustersRepository.findOne({ where: { status: ClusterStatus.ACTIVE, id: undefined as any }, }); // Use raw query to exclude the deleted cluster const activeReplacement = await this.clustersRepository.createQueryBuilder('c').where('c.id != :id', { id }).andWhere('c.status = :status', { status: ClusterStatus.ACTIVE }).getOne(); if (activeReplacement) { const result = await this.dataSource.query(`UPDATE applications SET "clusterId" = $1 WHERE "clusterId" = $2`, [activeReplacement.id, id]); const count = result?.[1] || 0; if (count > 0) { this.logger.log(`Reassigned ${count} application(s) from cluster "${cluster.name}" to "${activeReplacement.name}"`); } } else { // No replacement — nullify clusterId so apps aren't orphaned with a dangling FK await this.dataSource.query(`UPDATE applications SET "clusterId" = NULL WHERE "clusterId" = $1`, [id]); this.logger.warn(`No active replacement cluster — cleared clusterId for apps on "${cluster.name}"`); } } catch (e: any) { this.logger.warn(`Failed to reassign apps from cluster "${cluster.name}": ${e.message}`); } await this.clustersRepository.remove(cluster); this.logger.log(`Cluster "${cluster.name}" deleted`); // If deleted cluster was default, promote another active cluster if (wasDefault) { const newDefault = await this.clustersRepository.findOne({ where: { status: ClusterStatus.ACTIVE }, }); if (newDefault) { newDefault.isDefault = true; await this.clustersRepository.save(newDefault); this.logger.log(`Promoted cluster "${newDefault.name}" to default after deleting "${cluster.name}"`); } } } // ─── Cluster Pool Methods ───────────────────────────────────────── async createPool(dto: CreateClusterPoolDto): Promise { // Validate that all cluster IDs exist if (dto.clusterIds.length === 0) { throw new BadRequestException('Pool must contain at least one cluster'); } const clusters = await this.clustersRepository.find({ where: { id: In(dto.clusterIds) }, }); if (clusters.length !== dto.clusterIds.length) { const foundIds = clusters.map((c) => c.id); const missingIds = dto.clusterIds.filter((id) => !foundIds.includes(id)); throw new BadRequestException(`Clusters not found: ${missingIds.join(', ')}`); } if (dto.isDefault === true) { await this.poolsRepository.update({ isDefault: true }, { isDefault: false }); } const pool = this.poolsRepository.create({ ...dto, strategy: dto.strategy || 'weighted-resource', }); const saved = await this.poolsRepository.save(pool); this.logger.log(`Cluster pool "${saved.name}" created with ${dto.clusterIds.length} clusters (strategy: ${dto.strategy})`); return saved; } async findAllPools(): Promise { return this.poolsRepository.find({ order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' }, }); } /** * Public pool list — returns pools with resolved cluster names for UI. */ async findAllPoolsPublic(): Promise< (ClusterPool & { clusters: Pick[]; })[] > { const pools = await this.poolsRepository.find({ where: { isActive: true }, order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' }, }); const allClusterIds = [...new Set(pools.flatMap((p) => p.clusterIds))]; const clusters = allClusterIds.length > 0 ? await this.clustersRepository.find({ where: { id: In(allClusterIds) }, select: { id: true, name: true, region: true, provider: true, status: true, }, }) : []; const clusterMap = new Map(clusters.map((c) => [c.id, c])); return pools.map((pool) => ({ ...pool, clusters: pool.clusterIds.map((id) => clusterMap.get(id)).filter(Boolean) as Pick[], })); } async findOnePool(id: string): Promise { const pool = await this.poolsRepository.findOne({ where: { id } }); if (!pool) { throw new NotFoundException('Cluster pool not found'); } return pool; } async updatePool(id: string, dto: UpdateClusterPoolDto): Promise { const pool = await this.findOnePool(id); if (dto.clusterIds && dto.clusterIds.length > 0) { const clusters = await this.clustersRepository.find({ where: { id: In(dto.clusterIds) }, }); if (clusters.length !== dto.clusterIds.length) { const foundIds = clusters.map((c) => c.id); const missingIds = dto.clusterIds.filter((cid) => !foundIds.includes(cid)); throw new BadRequestException(`Clusters not found: ${missingIds.join(', ')}`); } } if (dto.isDefault === true) { await this.poolsRepository.update({ isDefault: true }, { isDefault: false }); } Object.assign(pool, dto); return this.poolsRepository.save(pool); } async deletePool(id: string): Promise { const pool = await this.findOnePool(id); await this.poolsRepository.remove(pool); this.poolRoundRobinIndices.delete(id); } /** * Get the optimal cluster from a specific pool using the pool's strategy. * Only considers ACTIVE clusters within the pool. */ async getOptimalClusterFromPool(poolId: string): Promise { const pool = await this.findOnePool(poolId); if (!pool.isActive) { throw new BadRequestException(`Pool "${pool.name}" is not active`); } const activeClusters = await this.clustersRepository.find({ where: { id: In(pool.clusterIds), status: ClusterStatus.ACTIVE, }, }); if (activeClusters.length === 0) { throw new NotFoundException(`No active clusters in pool "${pool.name}"`); } if (activeClusters.length === 1) { return activeClusters[0]; } if (pool.strategy === 'round-robin') { const idx = this.poolRoundRobinIndices.get(pool.id) || 0; const cluster = activeClusters[idx % activeClusters.length]; this.poolRoundRobinIndices.set(pool.id, idx + 1); this.logger.log(`Pool "${pool.name}" round-robin → cluster "${cluster.name}"`); return cluster; } // least-apps strategy const appCounts: { clusterId: string; count: string }[] = await this.dataSource.query( ` SELECT "clusterId", COUNT(*) as count FROM applications WHERE "clusterId" = ANY($1) GROUP BY "clusterId" `, [pool.clusterIds], ); const countMap = new Map(); for (const row of appCounts) { countMap.set(row.clusterId, parseInt(row.count, 10)); } activeClusters.sort((a, b) => { return (countMap.get(a.id) || 0) - (countMap.get(b.id) || 0); }); const selected = activeClusters[0]; this.logger.log(`Pool "${pool.name}" least-apps → cluster "${selected.name}" (${countMap.get(selected.id) || 0} apps)`); return selected; } /** * Get resource usage for a specific cluster — nodes, total CPU/memory, pod counts. */ async getClusterResources( id: string, options: { allowCached?: boolean } = {}, ): Promise<{ nodes: { name: string; status: string; roles: string; cpuCapacity: string; memoryCapacity: string; cpuAllocatable: string; memoryAllocatable: string; }[]; totalCpuCapacity: string; totalMemoryCapacity: string; totalCpuAllocatable: string; totalMemoryAllocatable: string; totalCpuRequested?: string; totalMemoryRequested?: string; cpuUtilization?: number; memoryUtilization?: number; podUtilization?: number; podCapacity?: number; podCount: number; nodeCount: number; readyNodeCount?: number; appCount: number; }> { const cluster = await this.findOne(id); if (options.allowCached !== false && cluster.availableResources && cluster.lastHealthCheckedAt && Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs) { return cluster.availableResources as any; } const kc = new k8s.KubeConfig(); registerKubeconfigNoProxy(cluster.kubeconfig); kc.loadFromString(cluster.kubeconfig); const coreApi = kc.makeApiClient(k8s.CoreV1Api); try { // Get nodes const nodesRes = await coreApi.listNode(); const nodes = nodesRes.items.map((node) => { const conditions = node.status?.conditions || []; const readyCondition = conditions.find((c) => c.type === 'Ready'); const roles = Object.keys(node.metadata?.labels || {}) .filter((l) => l.startsWith('node-role.kubernetes.io/')) .map((l) => l.replace('node-role.kubernetes.io/', '')) .join(', ') || 'worker'; return { name: node.metadata?.name || 'unknown', status: readyCondition?.status === 'True' ? 'Ready' : 'NotReady', roles, cpuCapacity: node.status?.capacity?.cpu || '0', memoryCapacity: node.status?.capacity?.memory || '0', cpuAllocatable: node.status?.allocatable?.cpu || '0', memoryAllocatable: node.status?.allocatable?.memory || '0', }; }); // Total capacity let totalCpuCap = 0; let totalMemCap = 0; let totalCpuAlloc = 0; let totalMemAlloc = 0; for (const node of nodes) { totalCpuCap += this.parseCpuToMillicores(node.cpuCapacity); totalMemCap += this.parseMemoryToMi(node.memoryCapacity); totalCpuAlloc += this.parseCpuToMillicores(node.cpuAllocatable); totalMemAlloc += this.parseMemoryToMi(node.memoryAllocatable); } // Get all pods count const podsRes = await coreApi.listPodForAllNamespaces(); const podCount = podsRes.items.length; let totalCpuRequested = 0; let totalMemoryRequested = 0; for (const pod of podsRes.items) { for (const container of pod.spec?.containers || []) { totalCpuRequested += this.parseCpuToMillicores(container.resources?.requests?.cpu || '0'); totalMemoryRequested += this.parseMemoryToMi(container.resources?.requests?.memory || '0'); } } // Get app count for this cluster const appCountResult = await this.dataSource.query(`SELECT COUNT(*) as count FROM applications WHERE "clusterId" = $1`, [id]); const appCount = parseInt(appCountResult[0]?.count || '0', 10); const readyNodeCount = nodes.filter((node) => node.status === 'Ready').length; const podCapacity = nodes.length * 110; const resources = { nodes, totalCpuCapacity: `${totalCpuCap}m`, totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`, totalCpuAllocatable: `${totalCpuAlloc}m`, totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`, totalCpuRequested: `${totalCpuRequested}m`, totalMemoryRequested: `${totalMemoryRequested.toFixed(0)}Mi`, cpuUtilization: this.utilizationRatio(totalCpuRequested, totalCpuAlloc), memoryUtilization: this.utilizationRatio(totalMemoryRequested, totalMemAlloc), podUtilization: this.utilizationRatio(podCount, podCapacity), podCapacity, podCount, nodeCount: nodes.length, readyNodeCount, appCount, }; const healthStatus: ClusterHealthStatus = readyNodeCount === nodes.length && nodes.length > 0 ? 'healthy' : readyNodeCount > 0 ? 'degraded' : 'unhealthy'; const healthMessage = `${readyNodeCount}/${nodes.length} nodes ready`; await this.clustersRepository.update(id, { healthStatus, healthMessage, lastHealthCheckedAt: new Date(), availableResources: resources as any, }); this.clusterHealthCache.set(id, { cluster: { ...cluster, healthStatus, healthMessage, lastHealthCheckedAt: new Date(), availableResources: resources, }, cachedAt: Date.now(), }); await this.recordHealthSnapshot(cluster, { status: healthStatus, message: healthMessage, resources, }); return resources; } catch (err: any) { this.logger.error(`Failed to get cluster resources for "${cluster.name}": ${err.message}`); await this.clustersRepository.update(id, { healthStatus: 'unhealthy', healthMessage: err.message, lastHealthCheckedAt: new Date(), }); this.clusterHealthCache.delete(id); await this.recordHealthSnapshot(cluster, { status: 'unhealthy', message: err.message, }); throw new BadRequestException(`Cannot fetch resources: ${err.message}`); } } /** * Bootstrap a newly-added cluster with the build infrastructure: * 1. cloudhost-builds namespace * 2. kaniko-builder ServiceAccount * 3. Docker Registry Deployment + PVC + Service (ClusterIP) + NodePort Service * 4. registry-credentials Secret (for Kaniko docker auth) */ async bootstrapCluster(kubeconfig: string): Promise { const kc = new k8s.KubeConfig(); registerKubeconfigNoProxy(kubeconfig); kc.loadFromString(kubeconfig); const coreApi = kc.makeApiClient(k8s.CoreV1Api); const appsApi = kc.makeApiClient(k8s.AppsV1Api); const buildNs = this.registryService.getBuildNamespace(); const saName = this.configService.get('build.serviceAccount') || 'kaniko-builder'; const registryHost = this.registryService.getRegistryHost(); this.logger.log(`Bootstrapping cluster — namespace: ${buildNs}`); // ── 1. Namespace ────────────────────────────────────────────── try { await coreApi.readNamespace({ name: buildNs }); this.logger.log(`Namespace "${buildNs}" already exists`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await coreApi.createNamespace({ body: { metadata: { name: buildNs } }, }); this.logger.log(`Created namespace "${buildNs}"`); } else { throw err; } } // ── 2. ServiceAccount for Kaniko ────────────────────────────── try { await coreApi.readNamespacedServiceAccount({ name: saName, namespace: buildNs, }); this.logger.log(`ServiceAccount "${saName}" already exists`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await coreApi.createNamespacedServiceAccount({ namespace: buildNs, body: { metadata: { name: saName, namespace: buildNs } }, }); this.logger.log(`Created ServiceAccount "${saName}"`); } else { throw err; } } // ── 3. Docker Registry PVC ──────────────────────────────────── const registryPvcName = 'registry-data'; try { await coreApi.readNamespacedPersistentVolumeClaim({ name: registryPvcName, namespace: buildNs, }); this.logger.log(`PVC "${registryPvcName}" already exists`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await coreApi.createNamespacedPersistentVolumeClaim({ namespace: buildNs, body: { metadata: { name: registryPvcName, namespace: buildNs }, spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: '10Gi' } }, }, }, }); this.logger.log(`Created PVC "${registryPvcName}" (10Gi)`); } else { throw err; } } // ── 4. Docker Registry Deployment ───────────────────────────── const registryDeployName = 'registry'; try { await appsApi.readNamespacedDeployment({ name: registryDeployName, namespace: buildNs, }); this.logger.log(`Deployment "${registryDeployName}" already exists`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await appsApi.createNamespacedDeployment({ namespace: buildNs, body: { metadata: { name: registryDeployName, namespace: buildNs, labels: { app: 'registry' }, }, spec: { replicas: 1, selector: { matchLabels: { app: 'registry' } }, template: { metadata: { labels: { app: 'registry' } }, spec: { containers: [ { name: 'registry', image: 'registry:2', ports: [{ containerPort: 5000 }], env: [ { name: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'true', }, ], volumeMounts: [ { name: 'registry-data', mountPath: '/var/lib/registry', }, ], resources: { requests: { cpu: '100m', memory: '128Mi' }, limits: { cpu: '500m', memory: '512Mi' }, }, }, ], volumes: [ { name: 'registry-data', persistentVolumeClaim: { claimName: registryPvcName }, }, ], }, }, }, }, }); this.logger.log(`Created Docker Registry Deployment`); } else { throw err; } } // ── 5. Registry ClusterIP Service (Kaniko push + app pull) ─── const registrySvcName = 'registry'; try { await coreApi.readNamespacedService({ name: registrySvcName, namespace: buildNs, }); this.logger.log(`Service "${registrySvcName}" already exists`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await coreApi.createNamespacedService({ namespace: buildNs, body: { metadata: { name: registrySvcName, namespace: buildNs, labels: { app: 'registry' }, }, spec: { type: 'ClusterIP', selector: { app: 'registry' }, ports: [{ port: 5000, targetPort: 5000 as any, protocol: 'TCP' }], }, }, }); this.logger.log(`Created Registry ClusterIP Service (port 5000)`); } else { throw err; } } // ── 6. Registry NodePort Service (optional host access :30500) ─ const registryNodePort = 30500; const registryNodePortName = 'registry-nodeport'; try { await coreApi.readNamespacedService({ name: registryNodePortName, namespace: buildNs, }); this.logger.log(`Service "${registryNodePortName}" already exists`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await coreApi.createNamespacedService({ namespace: buildNs, body: { metadata: { name: registryNodePortName, namespace: buildNs, labels: { app: 'registry' }, }, spec: { type: 'NodePort', selector: { app: 'registry' }, ports: [ { port: 5000, targetPort: 5000 as any, nodePort: registryNodePort, protocol: 'TCP', }, ], }, }, }); this.logger.log(`Created Registry NodePort Service (${registryNodePort} → 5000)`); } else { throw err; } } // ── 7. registry-credentials Secret (docker config for Kaniko) ─ const registrySecretName = 'registry-credentials'; const dockerConfig = this.registryService.buildDockerConfigJson(); const kanikoRegistrySecret: k8s.V1Secret = { metadata: { name: registrySecretName, namespace: buildNs }, type: 'kubernetes.io/dockerconfigjson', data: { '.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'), }, }; try { await coreApi.readNamespacedSecret({ name: registrySecretName, namespace: buildNs, }); await coreApi.replaceNamespacedSecret({ name: registrySecretName, namespace: buildNs, body: kanikoRegistrySecret, }); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await coreApi.createNamespacedSecret({ namespace: buildNs, body: kanikoRegistrySecret, }); this.logger.log(`Created registry-credentials Secret`); } else { throw err; } } await this.ensureK3sRegistryMirrors(coreApi, appsApi, registryHost); this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryHost}`); } /** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */ private async ensureK3sRegistryMirrors( coreApi: k8s.CoreV1Api, appsApi: k8s.AppsV1Api, registryHost: string, ): Promise { const namespace = 'kube-system'; const legacyDs = 'cloudhost-k3s-registry-config'; try { await appsApi.deleteNamespacedDaemonSet({ name: legacyDs, namespace }); this.logger.log(`Removed legacy DaemonSet "${legacyDs}"`); } catch (err: any) { if (err.code !== 404 && err.body?.code !== 404) { this.logger.warn(`Could not delete legacy DaemonSet "${legacyDs}": ${err.message}`); } } const { username, password } = this.registryService.getRegistryCredentials(); const dsName = 'cloudhost-k3s-registry-mirrors'; // containerd on the node does not use cluster DNS — mirror via ClusterIP (Harbor) // or loopback NodePort (legacy in-cluster registry). const mirrorEndpoint = await this.resolveK3sRegistryMirrorEndpoint(coreApi); const mirrorHost = mirrorEndpoint.replace(/^https?:\/\//, ''); const configureScript = [ 'set -e', 'REG=/host/etc/rancher/k3s/registries.yaml', 'mkdir -p /host/etc/rancher/k3s', 'cat > /tmp/cloudhost-registries.yaml </dev/null || true', 'fi', 'sleep infinity', ].join('\n'); const daemonSet: k8s.V1DaemonSet = { metadata: { name: dsName, namespace, labels: { 'app.kubernetes.io/managed-by': 'cloudhost' }, }, spec: { selector: { matchLabels: { app: dsName } }, template: { metadata: { labels: { app: dsName } }, spec: { hostPID: true, tolerations: [{ operator: 'Exists' }], containers: [ { name: 'configure', image: 'rancher/mirrored-library-busybox:1.36.1', command: ['/bin/sh', '-ec'], args: [configureScript], securityContext: { privileged: true }, volumeMounts: [{ name: 'etc', mountPath: '/host/etc' }], }, ], volumes: [ { name: 'etc', hostPath: { path: '/etc', type: 'Directory' }, }, ], }, }, }, }; try { await appsApi.readNamespacedDaemonSet({ name: dsName, namespace }); await appsApi.replaceNamespacedDaemonSet({ name: dsName, namespace, body: daemonSet, }); this.logger.log(`Updated DaemonSet "${dsName}"`); } catch (err: any) { if (err.code === 404 || err.body?.code === 404) { await appsApi.createNamespacedDaemonSet({ namespace, body: daemonSet }); this.logger.log(`Created DaemonSet "${dsName}"`); } else { throw err; } } } /** * containerd on nodes cannot resolve *.svc.cluster.local — use ClusterIP for * Harbor (harbor-core HTTP) or legacy registry NodePort on loopback. */ private async resolveK3sRegistryMirrorEndpoint(coreApi: k8s.CoreV1Api): Promise { const pushUrl = this.registryService.getRegistryPushUrl(); const platformNs = this.configService.get('platform.namespace') || 'cloudhost'; const harborCoreService = this.configService.get('registry.harborCoreService') || 'harbor-core'; if (pushUrl.includes('harbor-registry')) { try { const svc = await coreApi.readNamespacedService({ name: harborCoreService, namespace: platformNs, }); const clusterIp = svc.spec?.clusterIP; if (clusterIp) { return `http://${clusterIp}`; } } catch (err: any) { this.logger.warn( `Could not resolve ${harborCoreService} ClusterIP for k3s mirror: ${err.message}`, ); } } const registryNodePort = 30500; return `http://127.0.0.1:${registryNodePort}`; } private parseCpuToMillicores(cpu: string): number { if (!cpu || cpu === '0') return 0; if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000; if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000; if (cpu.endsWith('m')) return parseFloat(cpu); return parseFloat(cpu) * 1000; } private parseMemoryToMi(memory: string): number { if (!memory || memory === '0') return 0; if (memory.endsWith('Ki')) return parseFloat(memory) / 1024; if (memory.endsWith('Mi')) return parseFloat(memory); if (memory.endsWith('Gi')) return parseFloat(memory) * 1024; if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024; return parseFloat(memory) / (1024 * 1024); // bytes } private estimateApplicationRequest(dto: CreateApplicationDto): Record { const replicas = Math.max(dto.replicas || 1, 1); let cpuMillicores = this.parseCpuToMillicores(dto.cpuRequest || '100m') * replicas; let memoryMi = this.parseMemoryToMi(dto.memoryRequest || '128Mi') * replicas; let storageMi = this.parseStorageToMi(dto.dbStorageSize || '1Gi') + this.parseStorageToMi(dto.appStorageSize || '2Gi'); if (dto.databaseType && dto.databaseType !== 'none') { cpuMillicores += 100; memoryMi += 256; } for (const service of ['redis', 'rabbitmq'] as const) { const enabled = service === 'redis' ? dto.enableRedis : dto.enableRabbitmq; const custom = dto.optionalServiceResources?.[service]; if (!enabled && !custom) continue; cpuMillicores += this.parseCpuToMillicores(custom?.cpuRequest || custom?.cpuLimit || '100m'); memoryMi += this.parseMemoryToMi(custom?.memoryRequest || custom?.memoryLimit || '128Mi'); storageMi += (custom?.storageGi || 1) * 1024; } return { cpuMillicores, memoryMi, storageMi, replicas, podEstimate: replicas + (dto.databaseType && dto.databaseType !== 'none' ? 1 : 0) + (dto.enableRedis ? 1 : 0) + (dto.enableRabbitmq ? 1 : 0), }; } private async getCachedHealthyClusters(pool?: ClusterPool, excludeClusterIds: string[] = []): Promise { const query = this.clustersRepository.createQueryBuilder('cluster').where('cluster.status = :status', { status: ClusterStatus.ACTIVE }); if (pool?.clusterIds?.length) { query.andWhere('cluster.id IN (:...clusterIds)', { clusterIds: pool.clusterIds, }); } if (excludeClusterIds.length > 0) { query.andWhere('cluster.id NOT IN (:...excludeClusterIds)', { excludeClusterIds, }); } const clusters = await query.getMany(); const healthy: Cluster[] = []; for (const cluster of clusters) { const cached = await this.getClusterSnapshot(cluster); if (cached.status === ClusterStatus.ACTIVE && cached.healthStatus === 'healthy') { healthy.push(cached); } } return healthy; } private async getClusterSnapshot(cluster: Cluster): Promise { const cached = this.clusterHealthCache.get(cluster.id); if (cached && Date.now() - cached.cachedAt < this.cacheTtlMs) { return cached.cluster; } if (cluster.availableResources && cluster.lastHealthCheckedAt && Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs) { this.clusterHealthCache.set(cluster.id, { cluster, cachedAt: Date.now(), }); return cluster; } try { await this.getClusterResources(cluster.id, { allowCached: false }); } catch (err: any) { this.logger.warn(`Health refresh failed for cluster "${cluster.name}": ${err.message}`); } const fresh = await this.clustersRepository.findOne({ where: { id: cluster.id }, }); const snapshot = fresh || cluster; this.clusterHealthCache.set(cluster.id, { cluster: snapshot, cachedAt: Date.now(), }); return snapshot; } async refreshAllClusterHealth(): Promise { const clusters = await this.clustersRepository.find({ where: [{ status: ClusterStatus.ACTIVE }, { status: ClusterStatus.MAINTENANCE }], }); for (const cluster of clusters) { try { await this.getClusterResources(cluster.id, { allowCached: false }); } catch (err: any) { this.logger.warn(`Cluster health refresh failed for "${cluster.name}": ${err.message}`); } } } private getClusterRejectionReason( cluster: Cluster, estimatedRequest: Record, reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0, }, ): string | null { if (cluster.status !== ClusterStatus.ACTIVE) { return `status=${cluster.status}`; } if (cluster.healthStatus !== 'healthy') { return `health=${cluster.healthStatus}`; } const metrics = this.getResourceMetrics(cluster, estimatedRequest, 0, reserved); const { available, utilization } = metrics; if (available.cpuMillicores > 0 && available.cpuMillicores < estimatedRequest.cpuMillicores) { return `insufficient cpu (${available.cpuMillicores}m < ${estimatedRequest.cpuMillicores}m)`; } if (available.memoryMi > 0 && available.memoryMi < estimatedRequest.memoryMi) { return `insufficient memory (${available.memoryMi}Mi < ${estimatedRequest.memoryMi}Mi)`; } if (available.pods > 0 && available.pods < estimatedRequest.podEstimate) { return `pod pressure (${available.pods} pods available < ${estimatedRequest.podEstimate})`; } if (utilization.cpu >= this.overloadedCpuThreshold) { return `cpu overloaded (${Math.round(utilization.cpu * 100)}%)`; } if (utilization.memory >= this.overloadedMemoryThreshold) { return `memory overloaded (${Math.round(utilization.memory * 100)}%)`; } if (utilization.pods >= this.overloadedPodThreshold) { return `pod overloaded (${Math.round(utilization.pods * 100)}%)`; } return null; } private scoreCluster( cluster: Cluster, estimatedRequest: Record, appCount: number, strategy: PoolStrategy, desiredRegion?: string, reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0, }, ): number { const metrics = this.getResourceMetrics(cluster, estimatedRequest, appCount, reserved); const capacityScore = metrics.capacityScore; const appPenalty = Math.min(appCount, 100) * 0.75; if (strategy === 'round-robin') { return this.nextRoundRobinScore('allocator'); } if (strategy === 'weighted-round-robin') { return this.nextWeightedRoundRobinScore(cluster); } if (strategy === 'least-apps') { return 1000 - appPenalty + capacityScore * 100; } if (strategy === 'least-loaded') { return 1000 + capacityScore * 200 - metrics.utilization.average * 100 - appPenalty; } if (strategy === 'region-based') { const regionBoost = desiredRegion && cluster.region === desiredRegion ? 250 : 0; return regionBoost + (cluster.weight || 1) * 50 + capacityScore * 150 - appPenalty; } return (cluster.weight || 1) * 100 + capacityScore * 100 - appPenalty; } private getResourceMetrics( cluster: Cluster, estimatedRequest: Record, appCount: number, reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0, }, ): { available: { cpuMillicores: number; memoryMi: number; storageMi: number; pods: number; }; utilization: { cpu: number; memory: number; storage: number; pods: number; appPressure: number; average: number; }; capacityScore: number; } { const resources = cluster.availableResources || {}; const cpuCapacity = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || resources.totalCpuCapacity || '0'); const memoryCapacity = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || resources.totalMemoryCapacity || '0'); const storageCapacity = this.parseStorageToMi(resources.storageAllocatable || resources.storageCapacity || '0'); const cpuRequested = this.parseCpuToMillicores(resources.totalCpuRequested || resources.cpuRequested || '0'); const memoryRequested = this.parseMemoryToMi(resources.totalMemoryRequested || resources.memoryRequested || '0'); const storageUsed = this.parseStorageToMi(resources.storageUsed || '0'); const podCount = Number(resources.podCount || 0); const nodeCount = Number(resources.nodeCount || 0); const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0); // Include capacity reserved by in-flight deployments whose pods don't exist // on the cluster yet, so concurrent allocations don't over-commit the node. const cpuUsed = cpuRequested + (reserved.cpuMillicores || 0); const memoryUsed = memoryRequested + (reserved.memoryMi || 0); const podsUsed = podCount + (reserved.pods || 0); const available = { cpuMillicores: Math.max(cpuCapacity - cpuUsed, 0), memoryMi: Math.max(memoryCapacity - memoryUsed, 0), storageMi: storageCapacity > 0 ? Math.max(storageCapacity - storageUsed, 0) : 0, pods: podCapacity > 0 ? Math.max(podCapacity - podsUsed, 0) : 0, }; const utilization = { cpu: this.utilizationRatio(cpuUsed, cpuCapacity), memory: this.utilizationRatio(memoryUsed, memoryCapacity), storage: this.utilizationRatio(storageUsed, storageCapacity), pods: this.utilizationRatio(podsUsed, podCapacity), appPressure: Math.min(appCount / 100, 1), average: 0, }; utilization.average = utilization.cpu * 0.35 + utilization.memory * 0.35 + utilization.pods * 0.2 + utilization.appPressure * 0.1; const capacityScore = this.ratioScore(available.cpuMillicores, estimatedRequest.cpuMillicores) * 0.35 + this.ratioScore(available.memoryMi, estimatedRequest.memoryMi) * 0.35 + this.ratioScore(available.storageMi, estimatedRequest.storageMi) * 0.1 + this.ratioScore(available.pods, estimatedRequest.podEstimate) * 0.2; return { available, utilization, capacityScore }; } private resolveStrategy(strategy?: string): PoolStrategy { const strategies: PoolStrategy[] = ['round-robin', 'weighted-round-robin', 'least-apps', 'least-loaded', 'weighted-resource', 'region-based']; return strategies.includes(strategy as PoolStrategy) ? (strategy as PoolStrategy) : 'weighted-resource'; } private nextRoundRobinScore(scope: string): number { const idx = this.poolRoundRobinIndices.get(scope) || 0; this.poolRoundRobinIndices.set(scope, idx + 1); return 1000 - idx; } private nextWeightedRoundRobinScore(cluster: Cluster): number { const current = this.weightedRoundRobinState.get(cluster.id) || 0; const next = current + Math.max(cluster.weight || 1, 1); this.weightedRoundRobinState.set(cluster.id, next); return next; } private utilizationRatio(used: number, capacity: number): number { if (!capacity || capacity <= 0) return 0; return Math.min(Math.max(used / capacity, 0), 1); } private ratioScore(available: number, required: number): number { if (!available) return 0.7; if (!required) return 1; return Math.min(available / required, 10) / 10; } private async getAppCounts(clusterIds: string[]): Promise> { if (clusterIds.length === 0) { return new Map(); } const rows: { clusterId: string; count: string }[] = await this.dataSource.query( ` SELECT "clusterId", COUNT(*) as count FROM applications WHERE "clusterId" = ANY($1) AND "lifecycleStatus" = 'active' GROUP BY "clusterId" `, [clusterIds], ); return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)])); } /** * Capacity that has been allocated to deployments which are still building but * whose pods don't exist on the cluster yet (status pending/building). The * cluster's `availableResources` snapshot only reflects pods that already exist * (and is cached up to cacheTtlMs), so without this, two deployments started * within the same window both read the same stale capacity and can both be * placed on a cluster that only has room for one — the second then can't be * scheduled and hangs. We subtract these in-flight requests during allocation. * * @param excludeApplicationId skip an app's own in-flight deployment (used on * fallback re-allocation so the app doesn't reserve capacity against itself). */ private async getInFlightReservations(clusterIds: string[], excludeApplicationId?: string): Promise> { const result = new Map(); if (clusterIds.length === 0) { return result; } const rows: any[] = await this.dataSource.query( ` SELECT a.id as "applicationId", a."clusterId" as "clusterId", a."cpuRequest" as "cpuRequest", a."memoryRequest" as "memoryRequest", a.replicas as "replicas", a."databaseType" as "databaseType", a."enableRedis" as "enableRedis", a."enableRabbitmq" as "enableRabbitmq", a."appStorageSize" as "appStorageSize", a."dbStorageSize" as "dbStorageSize", a."optionalServiceResources" as "optionalServiceResources" FROM deployments d JOIN applications a ON a.id = d."applicationId" WHERE a."clusterId" = ANY($1) AND d.status IN ('pending', 'building') `, [clusterIds], ); for (const row of rows) { if (excludeApplicationId && row.applicationId === excludeApplicationId) { continue; } const est = this.estimateApplicationRequest(row as CreateApplicationDto); const current = result.get(row.clusterId) || { cpuMillicores: 0, memoryMi: 0, pods: 0, }; current.cpuMillicores += est.cpuMillicores; current.memoryMi += est.memoryMi; current.pods += est.podEstimate; result.set(row.clusterId, current); } return result; } private parseStorageToMi(storage: string): number { if (!storage) return 0; if (storage.endsWith('Ki')) return parseFloat(storage) / 1024; if (storage.endsWith('Mi')) return parseFloat(storage); if (storage.endsWith('Gi')) return parseFloat(storage) * 1024; if (storage.endsWith('Ti')) return parseFloat(storage) * 1024 * 1024; return parseFloat(storage) || 0; } private async recordHealthSnapshot( cluster: Cluster, snapshot: { status: ClusterHealthStatus; message?: string; resources?: Record; }, ): Promise { const resources = snapshot.resources || cluster.availableResources || {}; await this.healthRepository.save( this.healthRepository.create({ clusterId: cluster.id, status: snapshot.status, readyNodes: resources.readyNodeCount || resources.nodeCount || 0, nodeCount: resources.nodeCount || 0, cpuAllocatable: resources.totalCpuAllocatable || resources.cpuAllocatable || null, memoryAllocatable: resources.totalMemoryAllocatable || resources.memoryAllocatable || null, podCount: resources.podCount || 0, appCount: resources.appCount || 0, message: snapshot.message, resources, }), ); } private encryptKubeconfig(kubeconfig: string): string { if (!kubeconfig || kubeconfig.startsWith('enc:v1:')) { return kubeconfig; } const key = this.getKubeconfigEncryptionKey(); if (!key) { return kubeconfig; } const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(kubeconfig, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `enc:v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`; } private decryptKubeconfig(kubeconfig: string): string { if (!kubeconfig?.startsWith('enc:v1:')) { return kubeconfig; } const key = this.getKubeconfigEncryptionKey(); if (!key) { throw new BadRequestException('Kubeconfig is encrypted but CLUSTER_KUBECONFIG_KEY is not configured'); } const [, , ivRaw, tagRaw, encryptedRaw] = kubeconfig.split(':'); const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64')); decipher.setAuthTag(Buffer.from(tagRaw, 'base64')); return Buffer.concat([decipher.update(Buffer.from(encryptedRaw, 'base64')), decipher.final()]).toString('utf8'); } private withDecryptedKubeconfig(cluster: Cluster): Cluster { return { ...cluster, kubeconfig: this.decryptKubeconfig(cluster.kubeconfig), }; } private getKubeconfigEncryptionKey(): Buffer | null { const secret = this.configService.get('CLUSTER_KUBECONFIG_KEY') || this.configService.get('cluster.kubeconfigKey'); if (!secret) { return null; } if (secret.length === 64 && /^[0-9a-f]+$/i.test(secret)) { return Buffer.from(secret, 'hex'); } return crypto.createHash('sha256').update(secret).digest(); } }