diff --git a/backend/migrations/012_cluster_pool_allocation.sql b/backend/migrations/012_cluster_pool_allocation.sql new file mode 100644 index 0000000..dc33ab2 --- /dev/null +++ b/backend/migrations/012_cluster_pool_allocation.sql @@ -0,0 +1,110 @@ +-- Cluster pool allocation, health snapshots, and audit logs. + +ALTER TABLE clusters + ADD COLUMN IF NOT EXISTS weight INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS "healthStatus" VARCHAR NOT NULL DEFAULT 'unknown', + ADD COLUMN IF NOT EXISTS "lastHealthCheckedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "healthMessage" VARCHAR, + ADD COLUMN IF NOT EXISTS "availableResources" JSONB; + +ALTER TABLE cluster_pools + ADD COLUMN IF NOT EXISTS "isDefault" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 100; + +CREATE INDEX IF NOT EXISTS idx_clusters_status_health + ON clusters(status, "healthStatus"); + +CREATE INDEX IF NOT EXISTS idx_clusters_tags + ON clusters USING GIN(tags); + +CREATE INDEX IF NOT EXISTS idx_cluster_pools_default_priority + ON cluster_pools("isDefault", priority) + WHERE "isActive" = TRUE; + +CREATE TABLE IF NOT EXISTS cluster_health ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "clusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE, + status VARCHAR NOT NULL DEFAULT 'unknown', + "readyNodes" INTEGER NOT NULL DEFAULT 0, + "nodeCount" INTEGER NOT NULL DEFAULT 0, + "cpuAllocatable" VARCHAR, + "memoryAllocatable" VARCHAR, + "podCount" INTEGER NOT NULL DEFAULT 0, + "appCount" INTEGER NOT NULL DEFAULT 0, + message VARCHAR, + resources JSONB, + "checkedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cluster_health_cluster_checked + ON cluster_health("clusterId", "checkedAt" DESC); + +CREATE TABLE IF NOT EXISTS cluster_allocation_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL, + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "poolId" UUID REFERENCES cluster_pools(id) ON DELETE SET NULL, + "selectedClusterId" UUID REFERENCES clusters(id) ON DELETE SET NULL, + strategy VARCHAR NOT NULL DEFAULT 'weighted-resource', + "estimatedRequest" JSONB, + "candidateScores" JSONB, + "rejectionReasons" JSONB, + status VARCHAR NOT NULL DEFAULT 'success', + message VARCHAR, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_user_created + ON cluster_allocation_logs("userId", "createdAt" DESC); + +CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_app + ON cluster_allocation_logs("applicationId"); + +CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_cluster_created + ON cluster_allocation_logs("selectedClusterId", "createdAt" DESC); + +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS "clusterId" UUID, + ADD COLUMN IF NOT EXISTS "poolId" UUID; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'applications' AND column_name = 'clusterId' AND data_type <> 'uuid' + ) THEN + ALTER TABLE applications + ALTER COLUMN "clusterId" TYPE UUID USING NULLIF("clusterId", '')::uuid; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'applications' AND column_name = 'poolId' AND data_type <> 'uuid' + ) THEN + ALTER TABLE applications + ALTER COLUMN "poolId" TYPE UUID USING NULLIF("poolId", '')::uuid; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_applications_cluster' + ) THEN + ALTER TABLE applications + ADD CONSTRAINT fk_applications_cluster FOREIGN KEY ("clusterId") + REFERENCES clusters(id) ON DELETE SET NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_applications_pool' + ) THEN + ALTER TABLE applications + ADD CONSTRAINT fk_applications_pool FOREIGN KEY ("poolId") + REFERENCES cluster_pools(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_applications_cluster_id ON applications("clusterId"); +CREATE INDEX IF NOT EXISTS idx_applications_pool_id ON applications("poolId"); diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index f106616..3d5489d 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -23,49 +23,35 @@ export class ApplicationsService { ) {} 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) { + // End users and technical staff cannot influence placement; only admins may manually assign. + const isAdmin = userRole === UserRole.ADMIN; + if (!isAdmin) { if (dto.clusterId || dto.poolId) { - this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection — ignoring`); + 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; + let allocationLogId: string | undefined; - 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'); - } - } + if (isAdmin && clusterId) { + await this.clustersService.findOne(clusterId); + this.logger.log(`Manual cluster assignment for app "${dto.name}" -> cluster ${clusterId}`); } else { - this.logger.log(`Manual cluster assignment for app "${dto.name}" → cluster ${clusterId}`); + const allocationDto = { ...dto }; + if (poolId) { + allocationDto.poolId = poolId; + } + const allocation = await this.clustersService.selectClusterForApplication(allocationDto, userId); + clusterId = allocation.cluster.id; + poolId = allocation.pool?.id || (isAdmin ? poolId : undefined); + allocationLogId = allocation.allocationLogId; + if (!clusterId) { + throw new BadRequestException('No eligible cluster available for this application'); + } } // Generate database credentials if a database is requested @@ -109,7 +95,11 @@ export class ApplicationsService { platformDomain, ), }); - return this.appsRepository.save(app); + const saved = await this.appsRepository.save(app); + if (allocationLogId) { + await this.clustersService.attachAllocationToApplication(allocationLogId, saved.id); + } + return saved; } async findAllByUser(userId: string): Promise { diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 54f9820..37b96df 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -182,12 +182,12 @@ export class CreateApplicationDto { @IsNumber() port?: number; - @ApiPropertyOptional({ description: 'Cluster ID to deploy to (auto-assigns default if empty)' }) + @ApiPropertyOptional({ description: 'Admin-only manual cluster override. Ignored for non-admin users.' }) @IsOptional() @IsString() clusterId?: string; - @ApiPropertyOptional({ description: 'Cluster pool ID for load-balanced deployment' }) + @ApiPropertyOptional({ description: 'Admin-only pool override. Ignored for non-admin users.' }) @IsOptional() @IsString() poolId?: string; diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 1170dcc..0bf42b0 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -136,10 +136,10 @@ export class Application { @Column() userId: string; - @Column({ nullable: true }) + @Column({ type: 'uuid', nullable: true }) clusterId: string; - @Column({ nullable: true }) + @Column({ type: 'uuid', nullable: true }) poolId: string; // Cluster pool used for load-balanced assignment @OneToMany(() => Deployment, (deployment: Deployment) => deployment.application) diff --git a/backend/src/clusters/clusters.controller.ts b/backend/src/clusters/clusters.controller.ts index ed0ee11..ba18639 100644 --- a/backend/src/clusters/clusters.controller.ts +++ b/backend/src/clusters/clusters.controller.ts @@ -24,15 +24,17 @@ import { UserRole } from '../common/enums'; export class ClustersController { constructor(private readonly clustersService: ClustersService) {} - // ─── Public endpoints (any authenticated user) ──────────────────── + // ─── Admin-safe lookup endpoints (no end-user exposure) ─────────── @Get('public') + @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'List available clusters (public info, no kubeconfig)' }) async findAllPublic() { return this.clustersService.findAllPublic(); } @Get('pools/public') + @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'List active cluster pools with resolved cluster names' }) async findAllPoolsPublic() { return this.clustersService.findAllPoolsPublic(); @@ -41,90 +43,98 @@ export class ClustersController { // ─── Cluster admin endpoints ────────────────────────────────────── @Post() - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin)' }) async create(@Body() dto: CreateClusterDto) { return this.clustersService.create(dto); } @Get() - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'List all clusters (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List all clusters (Admin)' }) async findAll() { return this.clustersService.findAll(); } - @Get(':id/resources') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin/Technical)' }) - async getClusterResources(@Param('id') id: string) { - return this.clustersService.getClusterResources(id); - } - - @Get(':id') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Get cluster details (Admin/Technical)' }) - async findOne(@Param('id') id: string) { - return this.clustersService.findOne(id); - } - - @Patch(':id') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Update cluster configuration (Admin/Technical)' }) - async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) { - return this.clustersService.update(id, dto); - } - - @Post(':id/test') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin/Technical)' }) - async testConnection(@Param('id') id: string) { - return this.clustersService.testClusterById(id); - } - - @Delete(':id') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Remove a cluster (Admin/Technical)' }) - async delete(@Param('id') id: string) { - await this.clustersService.delete(id); - return { message: 'Cluster deleted' }; - } - // ─── Cluster Pool admin endpoints ───────────────────────────────── @Post('pools') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin)' }) async createPool(@Body() dto: CreateClusterPoolDto) { return this.clustersService.createPool(dto); } @Get('pools') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'List all cluster pools (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List all cluster pools (Admin)' }) async findAllPools() { return this.clustersService.findAllPools(); } @Get('pools/:id') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Get cluster pool details (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get cluster pool details (Admin)' }) async findOnePool(@Param('id') id: string) { return this.clustersService.findOnePool(id); } @Patch('pools/:id') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Update cluster pool (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update cluster pool (Admin)' }) async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) { return this.clustersService.updatePool(id, dto); } @Delete('pools/:id') - @Roles(UserRole.ADMIN, UserRole.TECHNICAL) - @ApiOperation({ summary: 'Delete a cluster pool (Admin/Technical)' }) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Delete a cluster pool (Admin)' }) async deletePool(@Param('id') id: string) { await this.clustersService.deletePool(id); return { message: 'Cluster pool deleted' }; } + + @Get('allocation-logs') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List recent cluster allocation decisions (Admin)' }) + async listAllocationLogs() { + return this.clustersService.listAllocationLogs(); + } + + @Get(':id/resources') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get cluster resource usage - nodes, CPU, memory, pods (Admin)' }) + async getClusterResources(@Param('id') id: string) { + return this.clustersService.getClusterResources(id); + } + + @Get(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get cluster details (Admin)' }) + async findOne(@Param('id') id: string) { + const { kubeconfig, ...cluster } = await this.clustersService.findOne(id); + return cluster; + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update cluster configuration (Admin)' }) + async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) { + return this.clustersService.update(id, dto); + } + + @Post(':id/test') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin)' }) + async testConnection(@Param('id') id: string) { + return this.clustersService.testClusterById(id); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Remove a cluster (Admin)' }) + async delete(@Param('id') id: string) { + await this.clustersService.delete(id); + return { message: 'Cluster deleted' }; + } } diff --git a/backend/src/clusters/clusters.module.ts b/backend/src/clusters/clusters.module.ts index 934b463..51297fa 100644 --- a/backend/src/clusters/clusters.module.ts +++ b/backend/src/clusters/clusters.module.ts @@ -4,11 +4,13 @@ import { ClustersService } from './clusters.service'; import { ClustersController } from './clusters.controller'; import { Cluster } from './entities/cluster.entity'; import { ClusterPool } from './entities/cluster-pool.entity'; +import { ClusterHealth } from './entities/cluster-health.entity'; +import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Cluster, ClusterPool]), + TypeOrmModule.forFeature([Cluster, ClusterPool, ClusterHealth, ClusterAllocationLog]), forwardRef(() => KubernetesModule), ], controllers: [ClustersController], diff --git a/backend/src/clusters/clusters.service.ts b/backend/src/clusters/clusters.service.ts index 0f1032c..4012dcc 100644 --- a/backend/src/clusters/clusters.service.ts +++ b/backend/src/clusters/clusters.service.ts @@ -3,12 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { Repository, DataSource, In } from 'typeorm'; import * as k8s from '@kubernetes/client-node'; -import { Cluster } from './entities/cluster.entity'; +import * as crypto from 'crypto'; +import { Cluster, ClusterHealthStatus } from './entities/cluster.entity'; import { ClusterPool } 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 { ElasticsearchService } from '../kubernetes/elasticsearch.service'; +import { CreateApplicationDto } from '../applications/dto/application.dto'; @Injectable() export class ClustersService { @@ -21,6 +25,10 @@ export class ClustersService { 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(() => ElasticsearchService)) @@ -34,7 +42,7 @@ export class ClustersService { async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> { try { const kc = new k8s.KubeConfig(); - kc.loadFromString(kubeconfig); + kc.loadFromString(this.decryptKubeconfig(kubeconfig)); const versionApi = kc.makeApiClient(k8s.VersionApi); const result = await versionApi.getCode(); @@ -74,13 +82,18 @@ export class ClustersService { 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(saved.kubeconfig).catch((err) => { + this.bootstrapCluster(usableCluster.kubeconfig).catch((err) => { this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`); }); @@ -89,12 +102,33 @@ export class ClustersService { this.logger.error(`Failed to deploy central logging on "${saved.name}": ${err.message}`); }); - return saved; + 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', 'name', 'description', 'status', 'apiServer', 'region', 'provider', 'isDefault', 'createdAt'], + select: [ + 'id', + 'name', + 'description', + 'status', + 'apiServer', + 'region', + 'provider', + 'isDefault', + 'weight', + 'tags', + 'healthStatus', + 'lastHealthCheckedAt', + 'healthMessage', + 'availableResources', + 'createdAt', + ], order: { createdAt: 'DESC' }, }); } @@ -104,7 +138,7 @@ export class ClustersService { if (!cluster) { throw new NotFoundException('Cluster not found'); } - return cluster; + return this.withDecryptedKubeconfig(cluster); } async getDefault(): Promise { @@ -126,7 +160,7 @@ export class ClustersService { if (!cluster) { throw new NotFoundException('No active cluster available'); } - return cluster; + return this.withDecryptedKubeconfig(cluster); } async update(id: string, dto: UpdateClusterDto): Promise { @@ -141,10 +175,11 @@ export class ClustersService { ); } 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(dto.kubeconfig).catch((err: any) => { + this.bootstrapCluster(this.decryptKubeconfig(dto.kubeconfig)).catch((err: any) => { this.logger.error(`Failed to bootstrap cluster "${cluster.name}": ${err.message}`); }); } @@ -159,7 +194,9 @@ export class ClustersService { } } Object.assign(cluster, dto); - return this.clustersRepository.save(cluster); + cluster.kubeconfig = this.encryptKubeconfig(cluster.kubeconfig); + const saved = await this.clustersRepository.save(cluster); + return this.withDecryptedKubeconfig(saved); } /** @@ -171,7 +208,17 @@ export class ClustersService { 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; @@ -188,6 +235,106 @@ export class ClustersService { }); } + async selectClusterForApplication( + dto: CreateApplicationDto, + userId: 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 candidateQuery = this.clustersRepository + .createQueryBuilder('cluster') + .where('cluster.status = :status', { status: ClusterStatus.ACTIVE }); + + if (pool?.clusterIds?.length) { + candidateQuery.andWhere('cluster.id IN (:...clusterIds)', { clusterIds: pool.clusterIds }); + } + + const candidates = await candidateQuery.getMany(); + const appCounts = await this.getAppCounts(candidates.map((cluster) => cluster.id)); + const candidateScores: Record[] = []; + const rejectionReasons: Record[] = []; + + for (const cluster of candidates) { + const rejection = this.getClusterRejectionReason(cluster, estimatedRequest); + if (rejection) { + rejectionReasons.push({ clusterId: cluster.id, clusterName: cluster.name, reason: rejection }); + continue; + } + + const score = this.scoreCluster(cluster, estimatedRequest, appCounts.get(cluster.id) || 0, pool?.strategy || 'weighted-resource'); + candidateScores.push({ + clusterId: cluster.id, + clusterName: cluster.name, + score, + weight: cluster.weight || 1, + appCount: appCounts.get(cluster.id) || 0, + healthStatus: cluster.healthStatus, + availableResources: cluster.availableResources || null, + }); + } + + if (candidateScores.length === 0) { + const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({ + userId, + poolId: pool?.id, + selectedClusterId: null, + strategy: pool?.strategy || 'weighted-resource', + estimatedRequest, + candidateScores, + rejectionReasons, + status: 'failed', + message: '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, + selectedClusterId: selected.id, + strategy: pool?.strategy || 'weighted-resource', + estimatedRequest, + candidateScores, + rejectionReasons, + status: 'success', + message: `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', 'pool'], + order: { createdAt: 'DESC' }, + take: limit, + }); + } + /** * Get the optimal cluster using load-balancing strategy. * Strategy: 'least-apps' — picks the active cluster with fewest deployed applications. @@ -308,14 +455,21 @@ export class ClustersService { throw new BadRequestException(`Clusters not found: ${missingIds.join(', ')}`); } - const pool = this.poolsRepository.create(dto); + 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: { createdAt: 'DESC' } }); + return this.poolsRepository.find({ order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' } }); } /** @@ -324,7 +478,7 @@ export class ClustersService { async findAllPoolsPublic(): Promise<(ClusterPool & { clusters: Pick[] })[]> { const pools = await this.poolsRepository.find({ where: { isActive: true }, - order: { createdAt: 'DESC' }, + order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' }, }); const allClusterIds = [...new Set(pools.flatMap((p) => p.clusterIds))]; @@ -367,6 +521,10 @@ export class ClustersService { } } + if (dto.isDefault === true) { + await this.poolsRepository.update({ isDefault: true }, { isDefault: false }); + } + Object.assign(pool, dto); return this.poolsRepository.save(pool); } @@ -443,6 +601,7 @@ export class ClustersService { totalMemoryAllocatable: string; podCount: number; nodeCount: number; + readyNodeCount?: number; appCount: number; }> { const cluster = await this.findOne(id); @@ -496,7 +655,8 @@ export class ClustersService { ); const appCount = parseInt(appCountResult[0]?.count || '0', 10); - return { + const readyNodeCount = nodes.filter((node) => node.status === 'Ready').length; + const resources = { nodes, totalCpuCapacity: `${totalCpuCap}m`, totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`, @@ -504,10 +664,33 @@ export class ClustersService { totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`, 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, + }); + 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(), + }); + await this.recordHealthSnapshot(cluster, { status: 'unhealthy', message: err.message }); throw new BadRequestException(`Cannot fetch resources: ${err.message}`); } } @@ -713,4 +896,198 @@ export class ClustersService { 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 getClusterRejectionReason(cluster: Cluster, estimatedRequest: Record): string | null { + if (cluster.status !== ClusterStatus.ACTIVE) { + return `status=${cluster.status}`; + } + + if (cluster.healthStatus && !['healthy', 'unknown'].includes(cluster.healthStatus)) { + return `health=${cluster.healthStatus}`; + } + + const resources = cluster.availableResources || {}; + const cpuFree = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || '0'); + const memoryFree = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || '0'); + const podCount = Number(resources.podCount || 0); + const nodeCount = Number(resources.nodeCount || 0); + const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0); + + if (cpuFree > 0 && cpuFree < estimatedRequest.cpuMillicores) { + return `insufficient cpu (${cpuFree}m < ${estimatedRequest.cpuMillicores}m)`; + } + if (memoryFree > 0 && memoryFree < estimatedRequest.memoryMi) { + return `insufficient memory (${memoryFree}Mi < ${estimatedRequest.memoryMi}Mi)`; + } + if (podCapacity > 0 && podCount + estimatedRequest.podEstimate > podCapacity) { + return `pod pressure (${podCount}/${podCapacity})`; + } + + return null; + } + + private scoreCluster( + cluster: Cluster, + estimatedRequest: Record, + appCount: number, + strategy: string, + ): number { + if (strategy === 'round-robin') { + const idx = this.poolRoundRobinIndices.get('allocator') || 0; + this.poolRoundRobinIndices.set('allocator', idx + 1); + return 1000 - idx; + } + + const resources = cluster.availableResources || {}; + const cpuFree = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || '0'); + const memoryFree = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || '0'); + const storageFree = this.parseStorageToMi(resources.storageAllocatable || resources.storageFree || '0'); + const podCount = Number(resources.podCount || 0); + const nodeCount = Number(resources.nodeCount || 0); + const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0); + + const capacityScore = + this.ratioScore(cpuFree, estimatedRequest.cpuMillicores) * 0.35 + + this.ratioScore(memoryFree, estimatedRequest.memoryMi) * 0.35 + + this.ratioScore(storageFree, estimatedRequest.storageMi) * 0.15 + + (podCapacity > 0 ? Math.max(0, 1 - podCount / podCapacity) : 0.7) * 0.15; + const appPenalty = Math.min(appCount, 100) * 0.75; + + if (strategy === 'least-apps') { + return 1000 - appPenalty + capacityScore * 100; + } + + return (cluster.weight || 1) * 100 + capacityScore * 100 - appPenalty; + } + + 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) + GROUP BY "clusterId" + `, [clusterIds]); + return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)])); + } + + 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(); + } } diff --git a/backend/src/clusters/dto/cluster-pool.dto.ts b/backend/src/clusters/dto/cluster-pool.dto.ts index b4a3538..f9766c2 100644 --- a/backend/src/clusters/dto/cluster-pool.dto.ts +++ b/backend/src/clusters/dto/cluster-pool.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsBoolean, IsArray, IsIn } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsArray, IsIn, IsNumber, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateClusterPoolDto { @@ -11,14 +11,25 @@ export class CreateClusterPoolDto { @IsString() description?: string; - @ApiProperty({ example: 'least-apps', enum: ['least-apps', 'round-robin'] }) - @IsIn(['least-apps', 'round-robin']) - strategy: 'least-apps' | 'round-robin'; + @ApiProperty({ example: 'weighted-resource', enum: ['least-apps', 'round-robin', 'weighted-resource'] }) + @IsIn(['least-apps', 'round-robin', 'weighted-resource']) + strategy: 'least-apps' | 'round-robin' | 'weighted-resource'; @ApiProperty({ example: ['uuid-1', 'uuid-2'], description: 'Array of cluster IDs in this pool' }) @IsArray() @IsString({ each: true }) clusterIds: string[]; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional({ example: 100 }) + @IsOptional() + @IsNumber() + @Min(1) + priority?: number; } export class UpdateClusterPoolDto { @@ -32,10 +43,10 @@ export class UpdateClusterPoolDto { @IsString() description?: string; - @ApiPropertyOptional({ enum: ['least-apps', 'round-robin'] }) + @ApiPropertyOptional({ enum: ['least-apps', 'round-robin', 'weighted-resource'] }) @IsOptional() - @IsIn(['least-apps', 'round-robin']) - strategy?: 'least-apps' | 'round-robin'; + @IsIn(['least-apps', 'round-robin', 'weighted-resource']) + strategy?: 'least-apps' | 'round-robin' | 'weighted-resource'; @ApiPropertyOptional({ description: 'Array of cluster IDs in this pool' }) @IsOptional() @@ -47,4 +58,15 @@ export class UpdateClusterPoolDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(1) + priority?: number; } diff --git a/backend/src/clusters/dto/cluster.dto.ts b/backend/src/clusters/dto/cluster.dto.ts index ad7522b..0ccb0d8 100644 --- a/backend/src/clusters/dto/cluster.dto.ts +++ b/backend/src/clusters/dto/cluster.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum, IsArray, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ClusterStatus } from '../../common/enums'; @@ -25,6 +25,18 @@ export class CreateClusterDto { @IsString() region?: string; + @ApiPropertyOptional({ example: 10, description: 'Relative placement weight; higher means more preferred' }) + @IsOptional() + @IsNumber() + @Min(1) + weight?: number; + + @ApiPropertyOptional({ example: ['ssd', 'iran', 'production'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + @ApiPropertyOptional({ example: 'aws' }) @IsOptional() @IsString() @@ -62,6 +74,16 @@ export class UpdateClusterDto { @IsString() description?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + region?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + provider?: string; + @ApiPropertyOptional() @IsOptional() @IsEnum(ClusterStatus) @@ -77,6 +99,18 @@ export class UpdateClusterDto { @IsBoolean() isDefault?: boolean; + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(1) + weight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/backend/src/clusters/entities/cluster-allocation-log.entity.ts b/backend/src/clusters/entities/cluster-allocation-log.entity.ts new file mode 100644 index 0000000..b091d4b --- /dev/null +++ b/backend/src/clusters/entities/cluster-allocation-log.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Cluster } from './cluster.entity'; +import { ClusterPool, PoolStrategy } from './cluster-pool.entity'; +import { Application } from '../../applications/entities/application.entity'; + +export type ClusterAllocationStatus = 'success' | 'failed'; + +@Entity('cluster_allocation_logs') +export class ClusterAllocationLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true }) + applicationId?: string | null; + + @ManyToOne(() => Application, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'applicationId' }) + application: Application; + + @Column() + userId: string; + + @Column({ nullable: true }) + poolId?: string | null; + + @ManyToOne(() => ClusterPool, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'poolId' }) + pool: ClusterPool; + + @Column({ nullable: true }) + selectedClusterId?: string | null; + + @ManyToOne(() => Cluster, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'selectedClusterId' }) + selectedCluster: Cluster; + + @Column({ default: 'weighted-resource' }) + strategy: PoolStrategy; + + @Column({ type: 'jsonb', nullable: true }) + estimatedRequest: Record; + + @Column({ type: 'jsonb', nullable: true }) + candidateScores: Record[]; + + @Column({ type: 'jsonb', nullable: true }) + rejectionReasons: Record[]; + + @Column({ default: 'success' }) + status: ClusterAllocationStatus; + + @Column({ nullable: true }) + message: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/clusters/entities/cluster-health.entity.ts b/backend/src/clusters/entities/cluster-health.entity.ts new file mode 100644 index 0000000..27bf919 --- /dev/null +++ b/backend/src/clusters/entities/cluster-health.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Cluster, ClusterHealthStatus } from './cluster.entity'; + +@Entity('cluster_health') +export class ClusterHealth { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + clusterId: string; + + @ManyToOne(() => Cluster, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'clusterId' }) + cluster: Cluster; + + @Column({ default: 'unknown' }) + status: ClusterHealthStatus; + + @Column({ default: 0 }) + readyNodes: number; + + @Column({ default: 0 }) + nodeCount: number; + + @Column({ nullable: true }) + cpuAllocatable: string; + + @Column({ nullable: true }) + memoryAllocatable: string; + + @Column({ default: 0 }) + podCount: number; + + @Column({ default: 0 }) + appCount: number; + + @Column({ nullable: true }) + message: string; + + @Column({ type: 'jsonb', nullable: true }) + resources: Record; + + @CreateDateColumn() + checkedAt: Date; +} diff --git a/backend/src/clusters/entities/cluster-pool.entity.ts b/backend/src/clusters/entities/cluster-pool.entity.ts index 4bd99e4..0a3d76d 100644 --- a/backend/src/clusters/entities/cluster-pool.entity.ts +++ b/backend/src/clusters/entities/cluster-pool.entity.ts @@ -6,7 +6,7 @@ import { UpdateDateColumn, } from 'typeorm'; -export type PoolStrategy = 'least-apps' | 'round-robin'; +export type PoolStrategy = 'least-apps' | 'round-robin' | 'weighted-resource'; @Entity('cluster_pools') export class ClusterPool { @@ -32,6 +32,12 @@ export class ClusterPool { @Column({ default: true }) isActive: boolean; + @Column({ default: false }) + isDefault: boolean; + + @Column({ default: 100 }) + priority: number; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/clusters/entities/cluster.entity.ts b/backend/src/clusters/entities/cluster.entity.ts index dd54212..acb86db 100644 --- a/backend/src/clusters/entities/cluster.entity.ts +++ b/backend/src/clusters/entities/cluster.entity.ts @@ -7,6 +7,8 @@ import { } from 'typeorm'; import { ClusterStatus } from '../../common/enums'; +export type ClusterHealthStatus = 'unknown' | 'healthy' | 'degraded' | 'unhealthy'; + @Entity('clusters') export class Cluster { @PrimaryGeneratedColumn('uuid') @@ -30,6 +32,24 @@ export class Cluster { @Column({ nullable: true }) region: string; + @Column({ default: 1 }) + weight: number; + + @Column({ type: 'jsonb', default: [] }) + tags: string[]; + + @Column({ default: 'unknown' }) + healthStatus: ClusterHealthStatus; + + @Column({ type: 'timestamptz', nullable: true }) + lastHealthCheckedAt: Date; + + @Column({ nullable: true }) + healthMessage: string; + + @Column({ type: 'jsonb', nullable: true }) + availableResources: Record; + @Column({ nullable: true }) provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal' diff --git a/frontend/src/app/dashboard/admin/clusters/page.tsx b/frontend/src/app/dashboard/admin/clusters/page.tsx index e7b6c40..ce48b0f 100644 --- a/frontend/src/app/dashboard/admin/clusters/page.tsx +++ b/frontend/src/app/dashboard/admin/clusters/page.tsx @@ -227,6 +227,8 @@ export default function AdminClustersPage() { kubeconfig: '', region: '', provider: '', + weight: 1, + tags: '', isDefault: false, }); @@ -236,12 +238,16 @@ export default function AdminClustersPage() { }); const createMutation = useMutation({ - mutationFn: (data: typeof form) => api.post('/clusters', data), + mutationFn: (data: typeof form) => api.post('/clusters', { + ...data, + tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean), + weight: Number(data.weight) || 1, + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); toast.success('Cluster added & connection verified ✓'); setShowForm(false); - setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', isDefault: false }); + setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false }); }, onError: (err: any) => { const message = err?.response?.data?.message || 'Failed to add cluster'; @@ -333,6 +339,25 @@ export default function AdminClustersPage() { +
+ + setForm({ ...form, weight: Number(e.target.value) || 1 })} + /> +
+
+ + setForm({ ...form, tags: e.target.value })} + /> +
@@ -410,10 +435,39 @@ export default function AdminClustersPage() { }`}> {cluster.status} + + health: {cluster.healthStatus || 'unknown'} + + weight {cluster.weight || 1}

{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}

+ {cluster.healthMessage && ( +

+ {cluster.healthMessage} + {cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''} +

+ )} + {cluster.tags?.length > 0 && ( +
+ {cluster.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {cluster.availableResources && ( +

+ CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'} +

+ )}
diff --git a/frontend/src/app/dashboard/admin/pools/page.tsx b/frontend/src/app/dashboard/admin/pools/page.tsx index 2c0be14..61f15a0 100644 --- a/frontend/src/app/dashboard/admin/pools/page.tsx +++ b/frontend/src/app/dashboard/admin/pools/page.tsx @@ -16,8 +16,10 @@ export default function AdminPoolsPage() { const [form, setForm] = useState({ name: '', description: '', - strategy: 'least-apps' as 'least-apps' | 'round-robin', + strategy: 'weighted-resource' as 'least-apps' | 'round-robin' | 'weighted-resource', clusterIds: [] as string[], + isDefault: false, + priority: 100, }); const { data: pools = [], isLoading } = useQuery({ @@ -66,7 +68,7 @@ export default function AdminPoolsPage() { const resetForm = () => { setShowForm(false); setEditingPool(null); - setForm({ name: '', description: '', strategy: 'least-apps', clusterIds: [] }); + setForm({ name: '', description: '', strategy: 'weighted-resource', clusterIds: [], isDefault: false, priority: 100 }); }; const startEdit = (pool: ClusterPool) => { @@ -76,6 +78,8 @@ export default function AdminPoolsPage() { description: pool.description || '', strategy: pool.strategy, clusterIds: pool.clusterIds, + isDefault: pool.isDefault || false, + priority: pool.priority || 100, }); setShowForm(true); }; @@ -138,10 +142,21 @@ export default function AdminPoolsPage() { value={form.strategy} onChange={(e) => setForm({ ...form, strategy: e.target.value as any })} > +
+
+ + setForm({ ...form, priority: Number(e.target.value) || 100 })} + /> +
@@ -154,6 +169,15 @@ export default function AdminPoolsPage() { />
+ + {/* Cluster selection */}
@@ -206,6 +230,17 @@ export default function AdminPoolsPage() { }`}> {cluster.status} + + {cluster.healthStatus || 'unknown'} + ); @@ -278,8 +313,14 @@ export default function AdminPoolsPage() { {pool.isActive ? 'Active' : 'Inactive'} - {pool.strategy === 'least-apps' ? <> Least Apps : <> Round Robin} + {pool.strategy === 'weighted-resource' + ? <> Weighted Resource + : pool.strategy === 'least-apps' + ? <> Least Apps + : <> Round Robin} + {pool.isDefault && Default Pool} + Priority {pool.priority || 100} {pool.description && (

{pool.description}

@@ -299,7 +340,7 @@ export default function AdminPoolsPage() { {cluster.status === 'active' ? : } {cluster.name} - ({cluster.provider || 'N/A'} · {cluster.region || 'N/A'}) + ({cluster.provider || 'N/A'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1} · {cluster.healthStatus || 'unknown'}) )) : ( diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 89ff313..35f1ff9 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -9,6 +9,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import NextLink from 'next/link'; import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { useAuthStore } from '@/lib/store'; import { BuildProgressModal } from '@/components/build-progress-modal'; /** Matches backend multipart limit for POST /applications/:id/upload */ @@ -55,6 +56,8 @@ export default function AppDetailPage() { const queryClient = useQueryClient(); const confirm = useConfirm(); const appId = params.id as string; + const user = useAuthStore((s) => s.user); + const isAdmin = user?.role === 'admin'; const [showLogs, setShowLogs] = useState(false); const [logTab, setLogTab] = useState<'pod' | 'build'>('pod'); const fileInputRef = useRef(null); @@ -143,11 +146,13 @@ export default function AppDetailPage() { const { data: clusters = [] } = useQuery({ queryKey: ['clusters-public'], queryFn: () => api.get('/clusters/public').then((r) => r.data), + enabled: isAdmin, }); const { data: pools = [] } = useQuery({ queryKey: ['pools-public'], queryFn: () => api.get('/clusters/pools/public').then((r) => r.data), + enabled: isAdmin, }); // Fetch DB storage size @@ -1308,7 +1313,7 @@ export default function AppDetailPage() {
Port
{app.port}
- {app.clusterId && ( + {isAdmin && app.clusterId && (
Cluster
@@ -1316,7 +1321,7 @@ export default function AppDetailPage() {
)} - {app.poolId && ( + {isAdmin && app.poolId && (
Pool
diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 7d0fbac..bffd105 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -217,7 +217,7 @@ function minGiToFitFileBytes(bytes: number): number { export default function DeployPage() { const router = useRouter(); const user = useAuthStore((s) => s.user); - const isAdmin = user?.role === 'admin' || user?.role === 'technical'; + const isAdmin = user?.role === 'admin'; const [step, setStep] = useState(0); const [form, setForm] = useState({ name: '', @@ -388,6 +388,10 @@ export default function DeployPage() { : {}), ...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}), }); + if (!isAdmin) { + delete payload.clusterId; + delete payload.poolId; + } const res = await api.post('/applications', payload); const appId = res.data.id; @@ -466,6 +470,10 @@ export default function DeployPage() { : {}), ...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}), }); + if (!isAdmin) { + delete payload.clusterId; + delete payload.poolId; + } const res = await api.post('/applications', payload); const appId = res.data.id; @@ -622,6 +630,10 @@ export default function DeployPage() { if (enableCustomDomain && customDomainInput.trim()) { payload.customDomain = customDomainInput.trim(); } + if (!isAdmin) { + delete payload.clusterId; + delete payload.poolId; + } createMutation.mutate(sanitizePayloadForWordPressRuntime(payload)); }; @@ -2138,8 +2150,8 @@ export default function DeployPage() {

Resources & Configuration

- {/* Cluster Assignment Mode — Admin only */} - {isAdmin ? ( + {/* Cluster Assignment Mode — Super Admin only */} + {isAdmin && (
@@ -2267,7 +2279,11 @@ export default function DeployPage() { )}
- {pool.strategy === 'least-apps' ? <> Least Apps : <> Round Robin} + {pool.strategy === 'weighted-resource' + ? <> Weighted Resource + : pool.strategy === 'least-apps' + ? <> Least Apps + : <> Round Robin} {pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}: @@ -2301,18 +2317,6 @@ export default function DeployPage() {
)}
- ) : ( -
-
- -
-

Cluster Assignment

-

- Your app will be automatically deployed to the platform's default cluster -

-
-
-
)}
@@ -2574,16 +2578,18 @@ export default function DeployPage() { Token provided
)} -
- Cluster - - {isAdmin && clusterMode === 'manual' && form.clusterId - ? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}` - : isAdmin && clusterMode === 'pool' && form.poolId - ? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)` - : 'Default Cluster'} - -
+ {isAdmin && ( +
+ Cluster + + {clusterMode === 'manual' && form.clusterId + ? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}` + : clusterMode === 'pool' && form.poolId + ? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)` + : 'Automatic allocator'} + +
+ )}
CPU {form.cpuRequest} / {form.cpuLimit} diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index 3d1c694..e8c6ae1 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -53,8 +53,6 @@ const adminNavItems: NavItem[] = [ const technicalNavItems: NavItem[] = [ { href: '/dashboard/admin/users', label: 'Users', icon: }, { href: '/dashboard/admin/apps', label: 'All Applications', icon: }, - { href: '/dashboard/admin/clusters', label: 'Clusters', icon: }, - { href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: }, { href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: }, ]; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2ed0af4..ab7384a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -169,9 +169,15 @@ export interface Cluster { name: string; description?: string; status: 'active' | 'inactive' | 'maintenance'; + healthStatus?: 'unknown' | 'healthy' | 'degraded' | 'unhealthy'; + lastHealthCheckedAt?: string; + healthMessage?: string; apiServer: string; region?: string; provider?: string; + weight: number; + tags: string[]; + availableResources?: Record; isDefault: boolean; defaultCpuLimit: string; defaultMemoryLimit: string; @@ -241,15 +247,18 @@ export interface ClusterPublic { provider?: string; isDefault: boolean; status: 'active' | 'inactive' | 'maintenance'; + healthStatus?: 'unknown' | 'healthy' | 'degraded' | 'unhealthy'; } export interface ClusterPoolPublic { id: string; name: string; description?: string; - strategy: 'least-apps' | 'round-robin'; + strategy: 'least-apps' | 'round-robin' | 'weighted-resource'; clusterIds: string[]; isActive: boolean; + isDefault: boolean; + priority: number; clusters: Pick[]; } @@ -257,9 +266,41 @@ export interface ClusterPool { id: string; name: string; description?: string; - strategy: 'least-apps' | 'round-robin'; + strategy: 'least-apps' | 'round-robin' | 'weighted-resource'; clusterIds: string[]; isActive: boolean; + isDefault: boolean; + priority: number; + createdAt: string; +} + +export interface ClusterHealth { + id: string; + clusterId: string; + status: 'unknown' | 'healthy' | 'degraded' | 'unhealthy'; + readyNodes: number; + nodeCount: number; + cpuAllocatable?: string; + memoryAllocatable?: string; + podCount: number; + appCount: number; + message?: string; + resources?: Record; + checkedAt: string; +} + +export interface ClusterAllocationLog { + id: string; + applicationId?: string; + userId: string; + poolId?: string; + selectedClusterId?: string; + strategy: 'least-apps' | 'round-robin' | 'weighted-resource'; + estimatedRequest?: Record; + candidateScores?: Record[]; + rejectionReasons?: Record[]; + status: 'success' | 'failed'; + message?: string; createdAt: string; }