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
-2
View File
@@ -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.<ns>.svc.cluster.local).
REGISTRY_USERNAME=admin
REGISTRY_PASSWORD=registry_secret
@@ -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;
}
+68
View File
@@ -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<string, any>;
setValues?: Record<string, string>;
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).
*/
+221 -146
View File
@@ -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<string, unknown>;
}
const TOOL_STATUS_BADGE: Record<ClusterTool['status'], { label: string; cls: string }> = {
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<Record<string, string>>({});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['cluster-tools', clusterId] });
const installMutation = useMutation({
mutationFn: (params: Record<string, string>) =>
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 (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{tool.name}</h3>
<span className={`badge ${badge.cls}`}>{badge.label}</span>
<span className="badge badge-gray">{tool.category}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{tool.description}</p>
{tool.message && (
<p className="text-xs text-gray-400 mt-1">{tool.message}</p>
)}
{depsBlocked && !isInstalled && (
<p className="text-xs text-amber-600 mt-1">
Requires:{' '}
{unmetDeps
.map((d) => tools.find((t) => t.id === d)?.name || d)
.join(', ')}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{tool.status === 'installing' && (
<RotateCw className="w-4 h-4 text-yellow-600 animate-spin" />
)}
{isInstalled ? (
<button
type="button"
onClick={async () => {
const ok = await confirm({
title: `Remove ${tool.name}`,
message: `Uninstall "${tool.name}" from this cluster?`,
confirmText: 'Uninstall',
variant: 'danger',
});
if (ok) uninstallMutation.mutate();
}}
disabled={isBusy}
className="btn-secondary text-sm text-red-600 disabled:opacity-50"
>
{uninstallMutation.isPending ? 'Removing…' : 'Uninstall'}
</button>
) : (
<button
type="button"
onClick={startInstall}
disabled={isBusy || depsBlocked}
className="btn-primary text-sm disabled:opacity-50"
title={depsBlocked ? 'Install required tools first' : undefined}
>
{installMutation.isPending
? 'Installing…'
: tool.status === 'failed'
? 'Repair'
: 'Install'}
</button>
)}
</div>
</div>
{showForm && !isInstalled && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 animate-fade-in">
{tool.installFields.map((f) => (
<div key={f.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{f.label}{f.required ? ' *' : ''}
</label>
<input
type={f.type === 'email' ? 'email' : 'text'}
className="input-field text-sm"
placeholder={f.placeholder}
value={fields[f.key] || ''}
onChange={(e) => setFields({ ...fields, [f.key]: e.target.value })}
/>
{f.helpText && <p className="text-xs text-gray-400 mt-1">{f.helpText}</p>}
</div>
))}
<div className="flex gap-2">
<button
type="button"
onClick={submitForm}
disabled={installMutation.isPending}
className="btn-primary text-sm"
>
{installMutation.isPending ? 'Installing…' : 'Confirm install'}
</button>
<button type="button" onClick={() => setShowForm(false)} className="btn-ghost text-sm">
Cancel
</button>
</div>
</div>
)}
</div>
);
}
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<ClusterTool[]>({
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 (
<div className="card p-4 border border-indigo-100 bg-indigo-50/30">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ScrollText className="w-5 h-5 text-indigo-600" /> Central logging (Elasticsearch)
<ScrollText className="w-5 h-5 text-indigo-600" /> Tools Management
</h2>
<p className="text-sm text-gray-600 mt-1">
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.
</p>
</div>
<div className="flex flex-wrap gap-2 items-center">
{clusters.length > 1 && (
<select
className="input-field text-sm py-1.5 max-w-[200px]"
value={clusterId || ''}
onChange={(e) => onSelectCluster(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}{c.isDefault ? ' (default)' : ''}
</option>
))}
</select>
)}
{!status?.deployed ? (
<button
type="button"
onClick={() => deployMutation.mutate(clusterId)}
disabled={deployMutation.isPending || !clusterId}
className="btn-primary text-sm"
>
{deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
</button>
) : (
<button
type="button"
onClick={() => undeployMutation.mutate(clusterId)}
disabled={undeployMutation.isPending || !clusterId}
className="btn-secondary text-sm text-red-600"
>
Remove stack
</button>
)}
</div>
{clusters.length > 1 && (
<select
className="input-field text-sm py-1.5 max-w-[220px]"
value={clusterId || ''}
onChange={(e) => onSelectCluster(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}{c.isDefault ? ' (default)' : ''}
</option>
))}
</select>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Checking status</p>
) : status?.deployed ? (
<div className="text-sm space-y-2">
<p className="text-green-700 font-medium flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> Deployed · health: {status.health?.status || 'unknown'}
</p>
<div className="bg-white rounded-lg p-3 border border-gray-200">
<p className="text-xs font-medium text-gray-600 mb-1">Kibana (staff only)</p>
<div className="flex items-center gap-2 font-mono text-xs">
<code className="flex-1 break-all">{kibanaCmd}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(kibanaCmd);
toast.success('Copied');
}}
className="p-1 text-gray-500 hover:text-gray-800"
>
<Copy className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-gray-500 mt-1">Then open http://localhost:5601</p>
</div>
</div>
<p className="text-sm text-gray-500">Loading tools</p>
) : error ? (
<p className="text-sm text-red-500">Failed to load tools for this cluster.</p>
) : (
<div className="text-sm text-amber-700 space-y-1">
<p>
Not ready on this cluster
{status?.deployStatus ? ` (${status.deployStatus}${status.helmReleaseStatus ? ` · helm: ${status.helmReleaseStatus}` : ''})` : ''}.
</p>
<p className="text-gray-600">
New clusters install automatically; if that failed (e.g. missing StorageClass), click Deploy Elastic to install or repair.
</p>
<div className="space-y-3">
{clusterId &&
tools.map((tool) => (
<ToolRow key={tool.id} clusterId={clusterId} tool={tool} tools={tools} />
))}
</div>
)}
</div>
);
}
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 (
<span className="text-xs text-green-700 font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> Elastic
</span>
);
}
return (
<button
type="button"
onClick={() => deployMutation.mutate()}
disabled={deployMutation.isPending}
className="btn-ghost text-sm text-indigo-700"
title="Install or repair central Elasticsearch on this cluster"
>
<ScrollText className="w-3 h-3 inline" />
{deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
</button>
);
}
export default function AdminClustersPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
@@ -393,11 +467,13 @@ export default function AdminClustersPage() {
</button>
</div>
<CentralLoggingPanel
clusters={clusters}
selectedClusterId={loggingClusterId}
onSelectCluster={setLoggingClusterId}
/>
{clusters.length > 0 && (
<ClusterToolsPanel
clusters={clusters}
selectedClusterId={loggingClusterId}
onSelectCluster={setLoggingClusterId}
/>
)}
{showForm && (
<div className="card space-y-4 animate-slide-up">
@@ -566,7 +642,6 @@ export default function AdminClustersPage() {
>
<BarChart3 className="w-4 h-4 inline" /> Resources
</button>
<ClusterElasticButton clusterId={cluster.id} clusterName={cluster.name} />
<button
onClick={() => testMutation.mutate(cluster.id)}
disabled={testingId === cluster.id}