diff --git a/backend/.env.example b/backend/.env.example index 0bb706d..b9590e7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,8 +23,6 @@ REDIS_PORT=6379 # In-cluster Docker Registry (Kaniko push + app image pull — same URL) REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000 # REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000 -# Cluster bootstrap installs cloudhost-node-cluster-dns so nodes resolve *.cluster.local -# (required for kubelet image pulls via registry..svc.cluster.local). REGISTRY_USERNAME=admin REGISTRY_PASSWORD=registry_secret diff --git a/backend/src/clusters/cluster-tools.controller.ts b/backend/src/clusters/cluster-tools.controller.ts new file mode 100644 index 0000000..93ebaf2 --- /dev/null +++ b/backend/src/clusters/cluster-tools.controller.ts @@ -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); + } + + @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); + } +} diff --git a/backend/src/clusters/cluster-tools.service.ts b/backend/src/clusters/cluster-tools.service.ts new file mode 100644 index 0000000..43b663c --- /dev/null +++ b/backend/src/clusters/cluster-tools.service.ts @@ -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 { + 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 = {}, + ): 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 1–2 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, + ): 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 2–5 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 }> { + 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 = { + 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 { + 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, + ); + } +} diff --git a/backend/src/clusters/cluster-tools.types.ts b/backend/src/clusters/cluster-tools.types.ts new file mode 100644 index 0000000..83ddc93 --- /dev/null +++ b/backend/src/clusters/cluster-tools.types.ts @@ -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; +} diff --git a/backend/src/clusters/clusters.module.ts b/backend/src/clusters/clusters.module.ts index 51297fa..c40f538 100644 --- a/backend/src/clusters/clusters.module.ts +++ b/backend/src/clusters/clusters.module.ts @@ -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 {} diff --git a/backend/src/clusters/clusters.service.ts b/backend/src/clusters/clusters.service.ts index 9413402..4d2b751 100644 --- a/backend/src/clusters/clusters.service.ts +++ b/backend/src/clusters/clusters.service.ts @@ -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, 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 { - 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, diff --git a/backend/src/clusters/dto/install-tool.dto.ts b/backend/src/clusters/dto/install-tool.dto.ts new file mode 100644 index 0000000..5e3e277 --- /dev/null +++ b/backend/src/clusters/dto/install-tool.dto.ts @@ -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; +} diff --git a/backend/src/kubernetes/helm.service.ts b/backend/src/kubernetes/helm.service.ts index b750a13..f38c1d9 100644 --- a/backend/src/kubernetes/helm.service.ts +++ b/backend/src/kubernetes/helm.service.ts @@ -95,6 +95,74 @@ export class HelmService { } } + /** + * Install or upgrade a Helm release from a remote chart repository. + * Adds/refreshes the repo first, then runs `helm upgrade --install`. + * Used for third-party infrastructure tools (e.g. jetstack/cert-manager). + */ + async installRemoteChart(opts: { + repoName: string; + repoUrl: string; + chart: string; + version?: string; + releaseName: string; + namespace: string; + values?: Record; + setValues?: Record; + kubeconfig: string; + wait?: boolean; + timeout?: string; + }): Promise<{ stdout: string; stderr: string }> { + const kubeconfigFile = await this.writeTempKubeconfig(opts.kubeconfig); + const valuesFile = + opts.values && Object.keys(opts.values).length + ? await this.writeTempValues(opts.values) + : null; + + try { + await execFileAsync( + 'helm', + ['repo', 'add', opts.repoName, opts.repoUrl, '--force-update'], + { timeout: 60_000 }, + ); + await execFileAsync('helm', ['repo', 'update', opts.repoName], { + timeout: 120_000, + }); + + const args = [ + 'upgrade', '--install', + opts.releaseName, + opts.chart, + '--namespace', opts.namespace, + '--create-namespace', + '--history-max', '10', + '--kubeconfig', kubeconfigFile, + ]; + if (opts.version) args.push('--version', opts.version); + if (valuesFile) args.push('--values', valuesFile); + for (const [k, v] of Object.entries(opts.setValues || {})) { + args.push('--set', `${k}=${v}`); + } + if (opts.wait !== false) { + args.push('--wait', '--timeout', opts.timeout || '5m'); + } + + this.logger.log( + `Helm install remote: ${opts.releaseName} (${opts.chart}${opts.version ? `@${opts.version}` : ''}) in ${opts.namespace}`, + ); + const result = await execFileAsync('helm', args, { timeout: 660_000 }); + this.logger.log(`Helm release ${opts.releaseName} installed/upgraded successfully`); + return result; + } catch (error: any) { + this.logger.error( + `Helm remote install failed for ${opts.releaseName}: ${error.stderr || error.message}`, + ); + throw new Error(`Helm install failed: ${error.stderr || error.message}`); + } finally { + this.cleanupTempFiles(kubeconfigFile, ...(valuesFile ? [valuesFile] : [])); + } + } + /** * Install or upgrade the central logging stack (Elasticsearch + Kibana). */ diff --git a/frontend/src/app/dashboard/admin/clusters/page.tsx b/frontend/src/app/dashboard/admin/clusters/page.tsx index 494a2dc..835ca5d 100644 --- a/frontend/src/app/dashboard/admin/clusters/page.tsx +++ b/frontend/src/app/dashboard/admin/clusters/page.tsx @@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; import { toast } from 'react-toastify'; import type { Cluster, ClusterResources } from '@/types'; -import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText, Copy } from 'lucide-react'; +import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; function ResourcePanel({ clusterId }: { clusterId: string }) { @@ -128,7 +128,189 @@ function apiErrorMessage(err: unknown, fallback: string): string { return fallback; } -function CentralLoggingPanel({ +interface ClusterToolField { + key: string; + label: string; + type: 'text' | 'email'; + required: boolean; + placeholder?: string; + helpText?: string; +} + +interface ClusterTool { + id: string; + name: string; + description: string; + category: string; + dependencies: string[]; + installFields: ClusterToolField[]; + status: 'not_installed' | 'installing' | 'installed' | 'failed' | 'unknown'; + message?: string; + details?: Record; +} + +const TOOL_STATUS_BADGE: Record = { + installed: { label: 'Installed', cls: 'badge-green' }, + installing: { label: 'Installing…', cls: 'badge-yellow' }, + failed: { label: 'Failed', cls: 'badge-red' }, + not_installed: { label: 'Not installed', cls: 'badge-gray' }, + unknown: { label: 'Unknown', cls: 'badge-gray' }, +}; + +function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) { + const queryClient = useQueryClient(); + const confirm = useConfirm(); + const [showForm, setShowForm] = useState(false); + const [fields, setFields] = useState>({}); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['cluster-tools', clusterId] }); + + const installMutation = useMutation({ + mutationFn: (params: Record) => + api.post(`/clusters/${clusterId}/tools/${tool.id}/install`, params), + onSuccess: (res) => { + invalidate(); + setShowForm(false); + setFields({}); + toast.success(res.data?.message || `${tool.name} install started`); + }, + onError: (err) => toast.error(apiErrorMessage(err, `Failed to install ${tool.name}`)), + }); + + const uninstallMutation = useMutation({ + mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`), + onSuccess: (res) => { + invalidate(); + toast.success(res.data?.message || `${tool.name} removed`); + }, + onError: (err) => toast.error(apiErrorMessage(err, `Failed to remove ${tool.name}`)), + }); + + const unmetDeps = tool.dependencies.filter( + (depId) => tools.find((t) => t.id === depId)?.status !== 'installed', + ); + const depsBlocked = unmetDeps.length > 0; + const isInstalled = tool.status === 'installed'; + const isBusy = installMutation.isPending || uninstallMutation.isPending; + const badge = TOOL_STATUS_BADGE[tool.status]; + + const startInstall = () => { + if (tool.installFields.length > 0) { + setShowForm((s) => !s); + } else { + installMutation.mutate({}); + } + }; + + const submitForm = () => { + for (const f of tool.installFields) { + if (f.required && !fields[f.key]?.trim()) { + toast.error(`${f.label} is required`); + return; + } + } + installMutation.mutate(fields); + }; + + return ( +
+
+
+
+

{tool.name}

+ {badge.label} + {tool.category} +
+

{tool.description}

+ {tool.message && ( +

{tool.message}

+ )} + {depsBlocked && !isInstalled && ( +

+ Requires:{' '} + {unmetDeps + .map((d) => tools.find((t) => t.id === d)?.name || d) + .join(', ')} +

+ )} +
+
+ {tool.status === 'installing' && ( + + )} + {isInstalled ? ( + + ) : ( + + )} +
+
+ + {showForm && !isInstalled && ( +
+ {tool.installFields.map((f) => ( +
+ + setFields({ ...fields, [f.key]: e.target.value })} + /> + {f.helpText &&

{f.helpText}

} +
+ ))} +
+ + +
+
+ )} +
+ ); +} + +function ClusterToolsPanel({ clusters, selectedClusterId, onSelectCluster, @@ -137,167 +319,59 @@ function CentralLoggingPanel({ selectedClusterId: string | null; onSelectCluster: (id: string) => void; }) { - const queryClient = useQueryClient(); const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id; - const { data: status, isLoading } = useQuery({ - queryKey: ['admin-elasticsearch-status', clusterId], - queryFn: () => - api.get('/admin/elasticsearch/status', { params: clusterId ? { clusterId } : {} }).then((r) => r.data), + const { data: tools = [], isLoading, error } = useQuery({ + queryKey: ['cluster-tools', clusterId], + queryFn: () => api.get(`/clusters/${clusterId}/tools`).then((r) => r.data), enabled: !!clusterId, + refetchInterval: (query) => + (query.state.data || []).some((t) => t.status === 'installing') ? 8000 : 30000, }); - const deployMutation = useMutation({ - mutationFn: (targetClusterId?: string) => - api.post('/admin/elasticsearch/deploy', null, { - params: targetClusterId ? { clusterId: targetClusterId } : {}, - }), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); - toast.success(res.data?.message || 'Logging stack deployment started'); - }, - onError: (err) => toast.error(apiErrorMessage(err, 'Failed to deploy logging stack')), - }); - - const undeployMutation = useMutation({ - mutationFn: (targetClusterId?: string) => - api.delete('/admin/elasticsearch/undeploy', { - params: targetClusterId ? { clusterId: targetClusterId } : {}, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); - toast.success('Logging stack removed'); - }, - onError: (err) => toast.error(apiErrorMessage(err, 'Failed to remove logging stack')), - }); - - const kibanaCmd = 'kubectl port-forward svc/kibana 5601:5601 -n logging'; - return (

- Central logging (Elasticsearch) + Tools Management

- Required for the unified Logs page. Installed automatically via Helm when a cluster is registered. - End users never get Kibana access — staff use port-forward. + Install and manage infrastructure tools per cluster. Nothing is installed automatically — + add only what each cluster needs.

-
- {clusters.length > 1 && ( - - )} - {!status?.deployed ? ( - - ) : ( - - )} -
+ {clusters.length > 1 && ( + + )}
+ {isLoading ? ( -

Checking status…

- ) : status?.deployed ? ( -
-

- Deployed · health: {status.health?.status || 'unknown'} -

-
-

Kibana (staff only)

-
- {kibanaCmd} - -
-

Then open http://localhost:5601

-
-
+

Loading tools…

+ ) : error ? ( +

Failed to load tools for this cluster.

) : ( -
-

- Not ready on this cluster - {status?.deployStatus ? ` (${status.deployStatus}${status.helmReleaseStatus ? ` · helm: ${status.helmReleaseStatus}` : ''})` : ''}. -

-

- New clusters install automatically; if that failed (e.g. missing StorageClass), click Deploy Elastic to install or repair. -

+
+ {clusterId && + tools.map((tool) => ( + + ))}
)}
); } -function ClusterElasticButton({ clusterId, clusterName }: { clusterId: string; clusterName: string }) { - const queryClient = useQueryClient(); - const { data: status } = useQuery({ - queryKey: ['admin-elasticsearch-status', clusterId], - queryFn: () => api.get('/admin/elasticsearch/status', { params: { clusterId } }).then((r) => r.data), - }); - - const deployMutation = useMutation({ - mutationFn: () => api.post('/admin/elasticsearch/deploy', null, { params: { clusterId } }), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); - toast.success(res.data?.message || `Elasticsearch deploy started on ${clusterName}`); - }, - onError: (err) => toast.error(apiErrorMessage(err, `Failed to deploy Elasticsearch on ${clusterName}`)), - }); - - if (status?.deployed) { - return ( - - Elastic - - ); - } - - return ( - - ); -} - export default function AdminClustersPage() { const queryClient = useQueryClient(); const confirm = useConfirm(); @@ -393,11 +467,13 @@ export default function AdminClustersPage() {
- + {clusters.length > 0 && ( + + )} {showForm && (
@@ -566,7 +642,6 @@ export default function AdminClustersPage() { > Resources -