fix(apps): always attempt K8s cleanup on delete, cluster service fixes

- Delete endpoint no longer requires clusterId && latestImageTag
- Clusters: getDefault fallback logic, reassign apps on delete
- Unit tests for cluster default/delete logic
This commit is contained in:
keyhan
2026-04-22 16:44:43 +03:30
parent 8ca787d46e
commit 5f6eccc483
7 changed files with 423 additions and 38 deletions
+262 -2
View File
@@ -1,5 +1,6 @@
import { Injectable, NotFoundException, Logger, BadRequestException } 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 { Cluster } from './entities/cluster.entity';
@@ -20,6 +21,7 @@ export class ClustersService {
@InjectRepository(ClusterPool)
private poolsRepository: Repository<ClusterPool>,
private dataSource: DataSource,
private configService: ConfigService,
) {}
/**
@@ -73,6 +75,12 @@ export class ClustersService {
});
const saved = await this.clustersRepository.save(cluster);
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.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`);
});
return saved;
}
@@ -92,9 +100,23 @@ export class ClustersService {
}
async getDefault(): Promise<Cluster> {
const cluster = await this.clustersRepository.findOne({ where: { isDefault: true } });
// Prefer active default cluster; fall back to any active cluster
let cluster = await this.clustersRepository.findOne({
where: { isDefault: true, status: ClusterStatus.ACTIVE },
});
if (!cluster) {
throw new NotFoundException('No default cluster configured');
// 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 cluster;
}
@@ -112,6 +134,11 @@ export class ClustersService {
}
dto.status = ClusterStatus.ACTIVE;
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.logger.error(`Failed to bootstrap cluster "${cluster.name}": ${err.message}`);
});
}
if (dto.isDefault === true) {
@@ -206,7 +233,55 @@ export class ClustersService {
async delete(id: string): Promise<void> {
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 ─────────────────────────────────────────
@@ -429,6 +504,191 @@ export class ClustersService {
}
}
/**
* 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<void> {
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
const buildNs = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder';
const registryUrl = this.configService.get<string>('registry.url') || `registry.${buildNs}.svc.cluster.local:5000`;
this.logger.log(`Bootstrapping cluster — namespace: ${buildNs}`);
// ── 1. Namespace ──────────────────────────────────────────────
try {
await coreApi.readNamespace(buildNs);
this.logger.log(`Namespace "${buildNs}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespace({ metadata: { name: buildNs } });
this.logger.log(`Created namespace "${buildNs}"`);
} else {
throw err;
}
}
// ── 2. ServiceAccount for Kaniko ──────────────────────────────
try {
await coreApi.readNamespacedServiceAccount(saName, buildNs);
this.logger.log(`ServiceAccount "${saName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespacedServiceAccount(buildNs, {
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(registryPvcName, buildNs);
this.logger.log(`PVC "${registryPvcName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespacedPersistentVolumeClaim(buildNs, {
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(registryDeployName, buildNs);
this.logger.log(`Deployment "${registryDeployName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await appsApi.createNamespacedDeployment(buildNs, {
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 (for Kaniko to push) ────────
const registrySvcName = 'registry';
try {
await coreApi.readNamespacedService(registrySvcName, buildNs);
this.logger.log(`Service "${registrySvcName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespacedService(buildNs, {
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 (for kubelet to pull) ────────
const registryNodePortName = 'registry-nodeport';
try {
await coreApi.readNamespacedService(registryNodePortName, buildNs);
this.logger.log(`Service "${registryNodePortName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespacedService(buildNs, {
metadata: { name: registryNodePortName, namespace: buildNs, labels: { app: 'registry' } },
spec: {
type: 'NodePort',
selector: { app: 'registry' },
ports: [{ port: 5000, targetPort: 5000 as any, nodePort: 30500, protocol: 'TCP' }],
},
});
this.logger.log(`Created Registry NodePort Service (30500 → 5000)`);
} else {
throw err;
}
}
// ── 7. registry-credentials Secret (docker config for Kaniko) ─
const registrySecretName = 'registry-credentials';
try {
await coreApi.readNamespacedSecret(registrySecretName, buildNs);
this.logger.log(`Secret "${registrySecretName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
// Parse registry host (without port path) for the docker config
const dockerConfig = JSON.stringify({
auths: {
[registryUrl]: { auth: '' },
[`registry.${buildNs}.svc.cluster.local:5000`]: { auth: '' },
'localhost:30500': { auth: '' },
},
});
await coreApi.createNamespacedSecret(buildNs, {
metadata: { name: registrySecretName, namespace: buildNs },
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'),
},
});
this.logger.log(`Created registry-credentials Secret`);
} else {
throw err;
}
}
this.logger.log(`✅ Cluster bootstrap complete — build infrastructure ready`);
}
private parseCpuToMillicores(cpu: string): number {
if (!cpu || cpu === '0') return 0;
if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000;