Add per-cluster Tools Management and stop auto-installing side tools.

Introduce a catalog-driven Tools Management section under Clusters so
admins can install/uninstall infrastructure tools per cluster: cert-manager
(Helm/jetstack), ClusterIssuer (email + HTTP01 form, depends on cert-manager),
and central Elasticsearch. Cluster creation no longer auto-installs Elastic
or the cloudhost-node-cluster-dns DaemonSet; build bootstrap stays automatic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-05-31 16:55:51 +03:30
parent be2587dcf5
commit 786689e0fd
9 changed files with 798 additions and 247 deletions
@@ -0,0 +1,51 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ClusterToolsService } from './cluster-tools.service';
import { InstallToolDto } from './dto/install-tool.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
import { ClusterToolId } from './cluster-tools.types';
@ApiTags('Clusters - Tools')
@ApiBearerAuth()
@Controller('clusters/:id/tools')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class ClusterToolsController {
constructor(private readonly toolsService: ClusterToolsService) {}
@Get()
@ApiOperation({ summary: 'List installable tools and their status on a cluster (Admin)' })
async list(@Param('id') id: string) {
return this.toolsService.getToolsForCluster(id);
}
@Post(':toolId/install')
@ApiOperation({ summary: 'Install an infrastructure tool on a cluster (Admin)' })
async install(
@Param('id') id: string,
@Param('toolId') toolId: ClusterToolId,
@Body() dto: InstallToolDto,
) {
return this.toolsService.install(id, toolId, { ...dto } as Record<string, string>);
}
@Delete(':toolId')
@ApiOperation({ summary: 'Uninstall an infrastructure tool from a cluster (Admin)' })
async uninstall(
@Param('id') id: string,
@Param('toolId') toolId: ClusterToolId,
) {
return this.toolsService.uninstall(id, toolId);
}
}
@@ -0,0 +1,403 @@
import {
Injectable,
Logger,
BadRequestException,
Inject,
forwardRef,
} from '@nestjs/common';
import * as k8s from '@kubernetes/client-node';
import { ClustersService } from './clusters.service';
import { HelmService } from '../kubernetes/helm.service';
import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
import {
ClusterToolDefinition,
ClusterToolId,
ClusterToolState,
ClusterToolStatus,
} from './cluster-tools.types';
const CERT_MANAGER_RELEASE = 'cert-manager';
const CERT_MANAGER_NAMESPACE = 'cert-manager';
const CERT_MANAGER_REPO_NAME = 'jetstack';
const CERT_MANAGER_REPO_URL = 'https://charts.jetstack.io';
const CERT_MANAGER_CHART = 'jetstack/cert-manager';
const CLUSTER_ISSUER_NAME = 'letsencrypt-prod';
const ACME_PROD_SERVER = 'https://acme-v02.api.letsencrypt.org/directory';
const DEFAULT_INGRESS_CLASS = 'nginx';
const ISSUER_GROUP = 'cert-manager.io';
const ISSUER_VERSION = 'v1';
const ISSUER_PLURAL = 'clusterissuers';
@Injectable()
export class ClusterToolsService {
private readonly logger = new Logger(ClusterToolsService.name);
private readonly certManagerVersion = process.env.CERT_MANAGER_VERSION || 'v1.14.5';
private readonly catalog: ClusterToolDefinition[] = [
{
id: 'cert-manager',
name: 'cert-manager',
description:
'Automated TLS certificate management. Required before creating a ClusterIssuer.',
category: 'Certificates',
dependencies: [],
installFields: [],
},
{
id: 'cluster-issuer',
name: "ClusterIssuer (Let's Encrypt)",
description:
'Cluster-wide ACME issuer (letsencrypt-prod) using HTTP01 challenge. Requires cert-manager.',
category: 'Certificates',
dependencies: ['cert-manager'],
installFields: [
{
key: 'email',
label: 'ACME email',
type: 'email',
required: true,
placeholder: 'ops@example.com',
helpText: "Let's Encrypt sends expiry notices to this address.",
},
],
},
{
id: 'central-elastic',
name: 'Central Elasticsearch + Kibana',
description:
'Shared logging stack powering the unified Logs page. Installed via Helm in the "logging" namespace.',
category: 'Logging',
dependencies: [],
installFields: [],
},
];
constructor(
@Inject(forwardRef(() => ClustersService))
private readonly clustersService: ClustersService,
private readonly helmService: HelmService,
@Inject(forwardRef(() => ElasticsearchService))
private readonly elasticsearchService: ElasticsearchService,
) {}
getCatalog(): ClusterToolDefinition[] {
return this.catalog;
}
async getToolsForCluster(clusterId: string): Promise<ClusterToolState[]> {
const kubeconfig = await this.getKubeconfig(clusterId);
return Promise.all(
this.catalog.map(async (def) => {
try {
const { status, message, details } = await this.statusOf(
def.id,
clusterId,
kubeconfig,
);
return { ...def, status, message, details };
} catch (err: any) {
return {
...def,
status: 'unknown' as ClusterToolStatus,
message: err.message,
};
}
}),
);
}
async install(
clusterId: string,
toolId: ClusterToolId,
params: Record<string, string> = {},
): Promise<{ status: ClusterToolStatus; message: string }> {
const def = this.requireTool(toolId);
const kubeconfig = await this.getKubeconfig(clusterId);
// Enforce dependencies.
for (const depId of def.dependencies) {
const dep = await this.statusOf(depId, clusterId, kubeconfig);
if (dep.status !== 'installed') {
throw new BadRequestException(
`"${this.requireTool(depId).name}" must be installed before "${def.name}".`,
);
}
}
switch (toolId) {
case 'cert-manager':
return this.installCertManager(kubeconfig);
case 'cluster-issuer':
return this.installClusterIssuer(kubeconfig, params);
case 'central-elastic':
return this.installCentralElastic(clusterId);
}
}
async uninstall(
clusterId: string,
toolId: ClusterToolId,
): Promise<{ status: ClusterToolStatus; message: string }> {
this.requireTool(toolId);
const kubeconfig = await this.getKubeconfig(clusterId);
switch (toolId) {
case 'cert-manager':
return this.uninstallCertManager(kubeconfig);
case 'cluster-issuer':
return this.uninstallClusterIssuer(kubeconfig);
case 'central-elastic':
return this.uninstallCentralElastic(clusterId);
}
}
// ── cert-manager ───────────────────────────────────────────────────
private async installCertManager(
kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
await this.helmService.installRemoteChart({
repoName: CERT_MANAGER_REPO_NAME,
repoUrl: CERT_MANAGER_REPO_URL,
chart: CERT_MANAGER_CHART,
version: this.certManagerVersion,
releaseName: CERT_MANAGER_RELEASE,
namespace: CERT_MANAGER_NAMESPACE,
setValues: { installCRDs: 'true' },
kubeconfig,
wait: false,
});
return {
status: 'installing',
message:
'cert-manager install started. Pods are starting — allow 12 minutes to become ready.',
};
}
private async uninstallCertManager(
kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
await this.helmService.uninstall(
CERT_MANAGER_RELEASE,
CERT_MANAGER_NAMESPACE,
kubeconfig,
);
return { status: 'not_installed', message: 'cert-manager removed.' };
}
// ── ClusterIssuer ──────────────────────────────────────────────────
private async installClusterIssuer(
kubeconfig: string,
params: Record<string, string>,
): Promise<{ status: ClusterToolStatus; message: string }> {
const email = (params.email || '').trim();
if (!email) {
throw new BadRequestException('An ACME email is required for the ClusterIssuer.');
}
const ingressClass = (params.ingressClass || DEFAULT_INGRESS_CLASS).trim();
const api = this.customObjectsApi(kubeconfig);
const body = {
apiVersion: `${ISSUER_GROUP}/${ISSUER_VERSION}`,
kind: 'ClusterIssuer',
metadata: { name: CLUSTER_ISSUER_NAME },
spec: {
acme: {
server: ACME_PROD_SERVER,
email,
privateKeySecretRef: { name: `${CLUSTER_ISSUER_NAME}-account-key` },
solvers: [{ http01: { ingress: { class: ingressClass } } }],
},
},
};
try {
await api.getClusterCustomObject(
ISSUER_GROUP,
ISSUER_VERSION,
ISSUER_PLURAL,
CLUSTER_ISSUER_NAME,
);
await api.replaceClusterCustomObject(
ISSUER_GROUP,
ISSUER_VERSION,
ISSUER_PLURAL,
CLUSTER_ISSUER_NAME,
body,
);
this.logger.log(`Updated ClusterIssuer "${CLUSTER_ISSUER_NAME}"`);
} catch (err: any) {
if (this.isNotFound(err)) {
await api.createClusterCustomObject(
ISSUER_GROUP,
ISSUER_VERSION,
ISSUER_PLURAL,
body,
);
this.logger.log(`Created ClusterIssuer "${CLUSTER_ISSUER_NAME}"`);
} else if (this.isMissingCrd(err)) {
throw new BadRequestException(
'cert-manager CRDs are not available yet. Wait for cert-manager to finish installing, then retry.',
);
} else {
throw err;
}
}
return {
status: 'installing',
message: `ClusterIssuer "${CLUSTER_ISSUER_NAME}" created. It becomes ready once cert-manager registers the ACME account.`,
};
}
private async uninstallClusterIssuer(
kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
const api = this.customObjectsApi(kubeconfig);
try {
await api.deleteClusterCustomObject(
ISSUER_GROUP,
ISSUER_VERSION,
ISSUER_PLURAL,
CLUSTER_ISSUER_NAME,
);
} catch (err: any) {
if (!this.isNotFound(err) && !this.isMissingCrd(err)) throw err;
}
return { status: 'not_installed', message: `ClusterIssuer "${CLUSTER_ISSUER_NAME}" removed.` };
}
// ── Central Elasticsearch ──────────────────────────────────────────
private async installCentralElastic(
clusterId: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
const result = await this.elasticsearchService.deploy(clusterId);
return {
status: result.deploying ? 'installing' : 'installed',
message: result.deploying
? 'Logging stack installed. Allow 25 minutes for Elasticsearch and Kibana to become ready.'
: 'Elasticsearch and Kibana are ready.',
};
}
private async uninstallCentralElastic(
clusterId: string,
): Promise<{ status: ClusterToolStatus; message: string }> {
await this.elasticsearchService.undeploy(clusterId);
return {
status: 'not_installed',
message: 'Logging stack removed. The data PVC is preserved.',
};
}
// ── Status resolution ──────────────────────────────────────────────
private async statusOf(
toolId: ClusterToolId,
clusterId: string,
kubeconfig: string,
): Promise<{ status: ClusterToolStatus; message?: string; details?: Record<string, unknown> }> {
switch (toolId) {
case 'cert-manager': {
const helm = await this.helmService.status(
CERT_MANAGER_RELEASE,
CERT_MANAGER_NAMESPACE,
kubeconfig,
);
if (!helm) return { status: 'not_installed' };
return {
status: this.mapHelmStatus(helm.status),
message: `helm: ${helm.status}`,
details: { version: helm.appVersion, revision: helm.revision },
};
}
case 'cluster-issuer': {
const api = this.customObjectsApi(kubeconfig);
try {
const res: any = await api.getClusterCustomObject(
ISSUER_GROUP,
ISSUER_VERSION,
ISSUER_PLURAL,
CLUSTER_ISSUER_NAME,
);
const conditions: any[] = res.body?.status?.conditions || [];
const ready = conditions.find((c) => c.type === 'Ready');
const email = res.body?.spec?.acme?.email;
if (ready?.status === 'True') {
return { status: 'installed', message: 'Ready', details: { email } };
}
return {
status: 'installing',
message: ready?.message || 'Waiting for ACME account registration',
details: { email },
};
} catch (err: any) {
if (this.isNotFound(err) || this.isMissingCrd(err)) {
return { status: 'not_installed' };
}
throw err;
}
}
case 'central-elastic': {
const state = await this.elasticsearchService.getDeployState(clusterId);
const map: Record<string, ClusterToolStatus> = {
not_installed: 'not_installed',
installing: 'installing',
failed: 'failed',
ready: 'installed',
};
return {
status: map[state.status] || 'unknown',
message: state.helmReleaseStatus
? `helm: ${state.helmReleaseStatus}`
: undefined,
details: state.health ? { health: state.health.status } : undefined,
};
}
}
}
// ── Helpers ────────────────────────────────────────────────────────
private requireTool(toolId: ClusterToolId): ClusterToolDefinition {
const def = this.catalog.find((t) => t.id === toolId);
if (!def) throw new BadRequestException(`Unknown tool "${toolId}".`);
return def;
}
private async getKubeconfig(clusterId: string): Promise<string> {
const cluster = await this.clustersService.findOne(clusterId);
return cluster.kubeconfig;
}
private customObjectsApi(kubeconfig: string): k8s.CustomObjectsApi {
const kc = new k8s.KubeConfig();
registerKubeconfigNoProxy(kubeconfig);
kc.loadFromString(kubeconfig);
return kc.makeApiClient(k8s.CustomObjectsApi);
}
private mapHelmStatus(status: string): ClusterToolStatus {
if (status === 'deployed') return 'installed';
if (status === 'failed') return 'failed';
if (status?.startsWith('pending') || status === 'uninstalling') return 'installing';
return 'unknown';
}
private isNotFound(err: any): boolean {
return err?.statusCode === 404 || err?.body?.code === 404;
}
/** cert-manager CRD not yet registered → API returns 404 on the group or NotFound kind. */
private isMissingCrd(err: any): boolean {
const msg = (err?.body?.message || err?.message || '').toString();
return /could not find the requested resource|the server could not find|no matches for kind|NotFound/i.test(
msg,
);
}
}
@@ -0,0 +1,34 @@
export type ClusterToolId = 'cert-manager' | 'cluster-issuer' | 'central-elastic';
export type ClusterToolStatus =
| 'not_installed'
| 'installing'
| 'installed'
| 'failed'
| 'unknown';
export interface ClusterToolField {
key: string;
label: string;
type: 'text' | 'email';
required: boolean;
placeholder?: string;
helpText?: string;
}
export interface ClusterToolDefinition {
id: ClusterToolId;
name: string;
description: string;
category: string;
/** Tool ids that must be installed before this one. */
dependencies: ClusterToolId[];
/** Inputs collected from the user at install time. */
installFields: ClusterToolField[];
}
export interface ClusterToolState extends ClusterToolDefinition {
status: ClusterToolStatus;
message?: string;
details?: Record<string, unknown>;
}
+5 -3
View File
@@ -2,6 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClustersService } from './clusters.service';
import { ClustersController } from './clusters.controller';
import { ClusterToolsService } from './cluster-tools.service';
import { ClusterToolsController } from './cluster-tools.controller';
import { Cluster } from './entities/cluster.entity';
import { ClusterPool } from './entities/cluster-pool.entity';
import { ClusterHealth } from './entities/cluster-health.entity';
@@ -13,8 +15,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
TypeOrmModule.forFeature([Cluster, ClusterPool, ClusterHealth, ClusterAllocationLog]),
forwardRef(() => KubernetesModule),
],
controllers: [ClustersController],
providers: [ClustersService],
exports: [ClustersService],
controllers: [ClustersController, ClusterToolsController],
providers: [ClustersService, ClusterToolsService],
exports: [ClustersService, ClusterToolsService],
})
export class ClustersModule {}
+2 -96
View File
@@ -20,7 +20,6 @@ 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 { RegistryService } from '../kubernetes/registry.service';
import { CreateApplicationDto } from '../applications/dto/application.dto';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
@@ -53,8 +52,6 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
private allocationLogsRepository: Repository<ClusterAllocationLog>,
private dataSource: DataSource,
private configService: ConfigService,
@Inject(forwardRef(() => ElasticsearchService))
private elasticsearchService: ElasticsearchService,
@Inject(forwardRef(() => RegistryService))
private registryService: RegistryService,
) {}
@@ -140,10 +137,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`);
});
// Deploy central logging (Elasticsearch + Kibana) via Helm
this.elasticsearchService.deploy(saved.id).catch((err) => {
this.logger.error(`Failed to deploy central logging on "${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',
@@ -1005,100 +1000,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
}
}
await this.ensureNodeClusterDns(coreApi, appsApi);
await this.ensureK3sRegistryMirrors(appsApi, registryUrl);
this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`);
}
/**
* Kubelet/containerd pull images on the host network stack, which uses the node's
* resolver (often systemd-resolved) — not pod DNS. Forward *.cluster.local to CoreDNS
* so registry.cloudhost-builds.svc.cluster.local resolves during image pulls.
*/
private async ensureNodeClusterDns(
coreApi: k8s.CoreV1Api,
appsApi: k8s.AppsV1Api,
): Promise<void> {
const dsName = 'cloudhost-node-cluster-dns';
const namespace = 'kube-system';
let clusterDnsIp = '10.43.0.10';
try {
const dnsSvc = await coreApi.readNamespacedService('kube-dns', namespace);
clusterDnsIp = dnsSvc.body.spec?.clusterIP || clusterDnsIp;
} catch (err: any) {
this.logger.warn(
`Could not read kube-dns ClusterIP (${err.message}); using ${clusterDnsIp}`,
);
}
const configureScript = [
'set -e',
'CONF=/host/etc/systemd/resolved.conf.d/k8s-cluster-dns.conf',
'mkdir -p /host/etc/systemd/resolved.conf.d',
`cat > /tmp/k8s-cluster-dns.conf <<'EOF'`,
'[Resolve]',
`DNS=${clusterDnsIp}`,
'Domains=~cluster.local',
'EOF',
'if [ ! -f "$CONF" ] || ! cmp -s /tmp/k8s-cluster-dns.conf "$CONF"; then',
' cp /tmp/k8s-cluster-dns.conf "$CONF"',
' echo "Updated k8s-cluster-dns.conf"',
' if nsenter -t 1 -m -u -i -n -p -- systemctl is-active systemd-resolved >/dev/null 2>&1; then',
' nsenter -t 1 -m -u -i -n -p -- systemctl restart systemd-resolved',
' fi',
'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(dsName, namespace);
await appsApi.replaceNamespacedDaemonSet(dsName, namespace, daemonSet);
this.logger.log(`Updated DaemonSet "${dsName}" (cluster DNS ${clusterDnsIp})`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await appsApi.createNamespacedDaemonSet(namespace, daemonSet);
this.logger.log(`Created DaemonSet "${dsName}" (cluster DNS ${clusterDnsIp})`);
} else {
throw err;
}
}
}
/** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */
private async ensureK3sRegistryMirrors(
appsApi: k8s.AppsV1Api,
@@ -0,0 +1,14 @@
import { IsOptional, IsString, IsEmail } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class InstallToolDto {
@ApiPropertyOptional({ description: 'ACME email (required for ClusterIssuer)' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ description: 'Ingress class for HTTP01 solver', default: 'nginx' })
@IsOptional()
@IsString()
ingressClass?: string;
}