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:
@@ -23,8 +23,6 @@ REDIS_PORT=6379
|
|||||||
# In-cluster Docker Registry (Kaniko push + app image pull — same URL)
|
# In-cluster Docker Registry (Kaniko push + app image pull — same URL)
|
||||||
REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000
|
REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000
|
||||||
# REGISTRY_PULL_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_USERNAME=admin
|
||||||
REGISTRY_PASSWORD=registry_secret
|
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 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<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 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<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>;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ClustersService } from './clusters.service';
|
import { ClustersService } from './clusters.service';
|
||||||
import { ClustersController } from './clusters.controller';
|
import { ClustersController } from './clusters.controller';
|
||||||
|
import { ClusterToolsService } from './cluster-tools.service';
|
||||||
|
import { ClusterToolsController } from './cluster-tools.controller';
|
||||||
import { Cluster } from './entities/cluster.entity';
|
import { Cluster } from './entities/cluster.entity';
|
||||||
import { ClusterPool } from './entities/cluster-pool.entity';
|
import { ClusterPool } from './entities/cluster-pool.entity';
|
||||||
import { ClusterHealth } from './entities/cluster-health.entity';
|
import { ClusterHealth } from './entities/cluster-health.entity';
|
||||||
@@ -13,8 +15,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
|||||||
TypeOrmModule.forFeature([Cluster, ClusterPool, ClusterHealth, ClusterAllocationLog]),
|
TypeOrmModule.forFeature([Cluster, ClusterPool, ClusterHealth, ClusterAllocationLog]),
|
||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
],
|
],
|
||||||
controllers: [ClustersController],
|
controllers: [ClustersController, ClusterToolsController],
|
||||||
providers: [ClustersService],
|
providers: [ClustersService, ClusterToolsService],
|
||||||
exports: [ClustersService],
|
exports: [ClustersService, ClusterToolsService],
|
||||||
})
|
})
|
||||||
export class ClustersModule {}
|
export class ClustersModule {}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
|
|||||||
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||||
import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto';
|
import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto';
|
||||||
import { ClusterStatus } from '../common/enums';
|
import { ClusterStatus } from '../common/enums';
|
||||||
import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
|
|
||||||
import { RegistryService } from '../kubernetes/registry.service';
|
import { RegistryService } from '../kubernetes/registry.service';
|
||||||
import { CreateApplicationDto } from '../applications/dto/application.dto';
|
import { CreateApplicationDto } from '../applications/dto/application.dto';
|
||||||
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||||
@@ -53,8 +52,6 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private allocationLogsRepository: Repository<ClusterAllocationLog>,
|
private allocationLogsRepository: Repository<ClusterAllocationLog>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
@Inject(forwardRef(() => ElasticsearchService))
|
|
||||||
private elasticsearchService: ElasticsearchService,
|
|
||||||
@Inject(forwardRef(() => RegistryService))
|
@Inject(forwardRef(() => RegistryService))
|
||||||
private registryService: 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}`);
|
this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deploy central logging (Elasticsearch + Kibana) via Helm
|
// Infrastructure tools (central logging, cert-manager, ClusterIssuer, …) are no
|
||||||
this.elasticsearchService.deploy(saved.id).catch((err) => {
|
// longer auto-installed. Install them on demand via Cluster → Tools Management.
|
||||||
this.logger.error(`Failed to deploy central logging on "${saved.name}": ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.recordHealthSnapshot(saved, {
|
this.recordHealthSnapshot(saved, {
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
@@ -1005,100 +1000,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.ensureNodeClusterDns(coreApi, appsApi);
|
|
||||||
await this.ensureK3sRegistryMirrors(appsApi, registryUrl);
|
await this.ensureK3sRegistryMirrors(appsApi, registryUrl);
|
||||||
|
|
||||||
this.logger.log(`✅ Cluster bootstrap complete — registry: ${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. */
|
/** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */
|
||||||
private async ensureK3sRegistryMirrors(
|
private async ensureK3sRegistryMirrors(
|
||||||
appsApi: k8s.AppsV1Api,
|
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;
|
||||||
|
}
|
||||||
@@ -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).
|
* Install or upgrade the central logging stack (Elasticsearch + Kibana).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { Cluster, ClusterResources } from '@/types';
|
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';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
|
|
||||||
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||||
@@ -128,7 +128,189 @@ function apiErrorMessage(err: unknown, fallback: string): string {
|
|||||||
return fallback;
|
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,
|
clusters,
|
||||||
selectedClusterId,
|
selectedClusterId,
|
||||||
onSelectCluster,
|
onSelectCluster,
|
||||||
@@ -137,167 +319,59 @@ function CentralLoggingPanel({
|
|||||||
selectedClusterId: string | null;
|
selectedClusterId: string | null;
|
||||||
onSelectCluster: (id: string) => void;
|
onSelectCluster: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
||||||
|
|
||||||
const { data: status, isLoading } = useQuery({
|
const { data: tools = [], isLoading, error } = useQuery<ClusterTool[]>({
|
||||||
queryKey: ['admin-elasticsearch-status', clusterId],
|
queryKey: ['cluster-tools', clusterId],
|
||||||
queryFn: () =>
|
queryFn: () => api.get(`/clusters/${clusterId}/tools`).then((r) => r.data),
|
||||||
api.get('/admin/elasticsearch/status', { params: clusterId ? { clusterId } : {} }).then((r) => r.data),
|
|
||||||
enabled: !!clusterId,
|
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 (
|
return (
|
||||||
<div className="card p-4 border border-indigo-100 bg-indigo-50/30">
|
<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 className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<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>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Required for the unified Logs page. Installed automatically via Helm when a cluster is registered.
|
Install and manage infrastructure tools per cluster. Nothing is installed automatically —
|
||||||
End users never get Kibana access — staff use port-forward.
|
add only what each cluster needs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
{clusters.length > 1 && (
|
||||||
{clusters.length > 1 && (
|
<select
|
||||||
<select
|
className="input-field text-sm py-1.5 max-w-[220px]"
|
||||||
className="input-field text-sm py-1.5 max-w-[200px]"
|
value={clusterId || ''}
|
||||||
value={clusterId || ''}
|
onChange={(e) => onSelectCluster(e.target.value)}
|
||||||
onChange={(e) => onSelectCluster(e.target.value)}
|
>
|
||||||
>
|
{clusters.map((c) => (
|
||||||
{clusters.map((c) => (
|
<option key={c.id} value={c.id}>
|
||||||
<option key={c.id} value={c.id}>
|
{c.name}{c.isDefault ? ' (default)' : ''}
|
||||||
{c.name}{c.isDefault ? ' (default)' : ''}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
</select>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-gray-500">Checking status…</p>
|
<p className="text-sm text-gray-500">Loading tools…</p>
|
||||||
) : status?.deployed ? (
|
) : error ? (
|
||||||
<div className="text-sm space-y-2">
|
<p className="text-sm text-red-500">Failed to load tools for this cluster.</p>
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-amber-700 space-y-1">
|
<div className="space-y-3">
|
||||||
<p>
|
{clusterId &&
|
||||||
Not ready on this cluster
|
tools.map((tool) => (
|
||||||
{status?.deployStatus ? ` (${status.deployStatus}${status.helmReleaseStatus ? ` · helm: ${status.helmReleaseStatus}` : ''})` : ''}.
|
<ToolRow key={tool.id} clusterId={clusterId} tool={tool} tools={tools} />
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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() {
|
export default function AdminClustersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -393,11 +467,13 @@ export default function AdminClustersPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CentralLoggingPanel
|
{clusters.length > 0 && (
|
||||||
clusters={clusters}
|
<ClusterToolsPanel
|
||||||
selectedClusterId={loggingClusterId}
|
clusters={clusters}
|
||||||
onSelectCluster={setLoggingClusterId}
|
selectedClusterId={loggingClusterId}
|
||||||
/>
|
onSelectCluster={setLoggingClusterId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="card space-y-4 animate-slide-up">
|
<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
|
<BarChart3 className="w-4 h-4 inline" /> Resources
|
||||||
</button>
|
</button>
|
||||||
<ClusterElasticButton clusterId={cluster.id} clusterName={cluster.name} />
|
|
||||||
<button
|
<button
|
||||||
onClick={() => testMutation.mutate(cluster.id)}
|
onClick={() => testMutation.mutate(cluster.id)}
|
||||||
disabled={testingId === cluster.id}
|
disabled={testingId === cluster.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user