This commit is contained in:
keyhan
2026-04-05 15:22:01 +03:30
commit 33be1649c4
82 changed files with 23956 additions and 0 deletions
@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ClustersService } from './clusters.service';
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@ApiTags('Clusters')
@ApiBearerAuth()
@Controller('clusters')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class ClustersController {
constructor(private readonly clustersService: ClustersService) {}
@Post()
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin only)' })
async create(@Body() dto: CreateClusterDto) {
return this.clustersService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List all clusters (Admin only)' })
async findAll() {
return this.clustersService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get cluster details (Admin only)' })
async findOne(@Param('id') id: string) {
return this.clustersService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update cluster configuration (Admin only)' })
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
return this.clustersService.update(id, dto);
}
@Post(':id/test')
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin only)' })
async testConnection(@Param('id') id: string) {
return this.clustersService.testClusterById(id);
}
@Delete(':id')
@ApiOperation({ summary: 'Remove a cluster (Admin only)' })
async delete(@Param('id') id: string) {
await this.clustersService.delete(id);
return { message: 'Cluster deleted' };
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClustersService } from './clusters.service';
import { ClustersController } from './clusters.controller';
import { Cluster } from './entities/cluster.entity';
@Module({
imports: [TypeOrmModule.forFeature([Cluster])],
controllers: [ClustersController],
providers: [ClustersService],
exports: [ClustersService],
})
export class ClustersModule {}
+142
View File
@@ -0,0 +1,142 @@
import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as k8s from '@kubernetes/client-node';
import { Cluster } from './entities/cluster.entity';
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
import { ClusterStatus } from '../common/enums';
@Injectable()
export class ClustersService {
private readonly logger = new Logger(ClustersService.name);
constructor(
@InjectRepository(Cluster)
private clustersRepository: Repository<Cluster>,
) {}
/**
* 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 kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const versionApi = kc.makeApiClient(k8s.VersionApi);
const result = await versionApi.getCode();
const info = result.body;
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<Cluster> {
// 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,
status: ClusterStatus.ACTIVE, // Connection verified — mark active
});
const saved = await this.clustersRepository.save(cluster);
this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`);
return saved;
}
async findAll(): Promise<Cluster[]> {
return this.clustersRepository.find({
select: ['id', 'name', 'description', 'status', 'apiServer', 'region', 'provider', 'isDefault', 'createdAt'],
order: { createdAt: 'DESC' },
});
}
async findOne(id: string): Promise<Cluster> {
const cluster = await this.clustersRepository.findOne({ where: { id } });
if (!cluster) {
throw new NotFoundException('Cluster not found');
}
return cluster;
}
async getDefault(): Promise<Cluster> {
const cluster = await this.clustersRepository.findOne({ where: { isDefault: true } });
if (!cluster) {
throw new NotFoundException('No default cluster configured');
}
return cluster;
}
async update(id: string, dto: UpdateClusterDto): Promise<Cluster> {
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;
this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`);
}
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);
return this.clustersRepository.save(cluster);
}
/**
* 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;
await this.clustersRepository.save(cluster);
this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`);
return result;
}
async delete(id: string): Promise<void> {
const cluster = await this.findOne(id);
await this.clustersRepository.remove(cluster);
}
}
+94
View File
@@ -0,0 +1,94 @@
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ClusterStatus } from '../../common/enums';
export class CreateClusterDto {
@ApiProperty({ example: 'production-cluster' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'Main production K8s cluster' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 'apiVersion: v1\nclusters:\n- cluster:...' })
@IsString()
kubeconfig: string;
@ApiProperty({ example: 'https://k8s-api.example.com:6443' })
@IsString()
apiServer: string;
@ApiPropertyOptional({ example: 'us-east-1' })
@IsOptional()
@IsString()
region?: string;
@ApiPropertyOptional({ example: 'aws' })
@IsOptional()
@IsString()
provider?: string;
@ApiPropertyOptional({ example: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({ example: '4' })
@IsOptional()
@IsString()
defaultCpuLimit?: string;
@ApiPropertyOptional({ example: '8Gi' })
@IsOptional()
@IsString()
defaultMemoryLimit?: string;
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@IsNumber()
maxAppsPerUser?: number;
}
export class UpdateClusterDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEnum(ClusterStatus)
status?: ClusterStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
kubeconfig?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
defaultCpuLimit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
defaultMemoryLimit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
maxAppsPerUser?: number;
}
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { ClusterStatus } from '../../common/enums';
@Entity('clusters')
export class Cluster {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ nullable: true })
description: string;
@Column({ type: 'enum', enum: ClusterStatus, default: ClusterStatus.ACTIVE })
status: ClusterStatus;
@Column({ type: 'text' })
kubeconfig: string; // Encrypted kubeconfig content
@Column()
apiServer: string;
@Column({ nullable: true })
region: string;
@Column({ nullable: true })
provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal'
@Column({ default: false })
isDefault: boolean;
// Resource quotas (cluster-level defaults for new namespaces)
@Column({ default: '4' })
defaultCpuLimit: string;
@Column({ default: '8Gi' })
defaultMemoryLimit: string;
@Column({ default: 10 })
maxAppsPerUser: number;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}