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:
@@ -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 { 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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user