init
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user