Use in-cluster registry for builds and deploys; improve logging and cluster ops.

Remove external registry Ingress (repo.3fase.ir) and route Kaniko push and app pulls through the internal ClusterIP registry. Add RegistryService, ensure StorageClass and pull secrets on deploy, make Elasticsearch install/repair more resilient, and add per-cluster Deploy Elastic controls in admin UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-26 19:15:06 +03:30
parent 1ade52825c
commit d7594df9a0
20 changed files with 547 additions and 237 deletions
+6 -5
View File
@@ -2,6 +2,7 @@
NODE_ENV=development NODE_ENV=development
PORT=4000 PORT=4000
# Database # Database
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
@@ -19,12 +20,9 @@ JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
# Container Registry (same Docker Registry v2, two hostnames) # In-cluster Docker Registry (Kaniko push + app image pull — same URL)
# Internal — Kaniko/build pods push here (ClusterIP, HTTP, fast)
REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000 REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000
# External — kubelet pulls app images; also use for manual "docker push" (Ingress or NodePort) # REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000
REGISTRY_PULL_URL=repo.3fase.ir
# REGISTRY_PULL_URL=10.0.0.50:30500
REGISTRY_USERNAME=admin REGISTRY_USERNAME=admin
REGISTRY_PASSWORD=registry_secret REGISTRY_PASSWORD=registry_secret
@@ -47,6 +45,9 @@ BUILD_SERVICE_ACCOUNT=kaniko-builder
PLATFORM_DOMAIN=apps.cloudhost.local PLATFORM_DOMAIN=apps.cloudhost.local
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# PVC resize: use a dynamic StorageClass with allowVolumeExpansion (k3s: rancher.io/local-path) # PVC resize: use a dynamic StorageClass with allowVolumeExpansion (k3s: rancher.io/local-path)
# k3s: use local-path and skip creating a custom class (set CREATE=false)
# PLATFORM_STORAGE_CLASS=local-path
# PLATFORM_CREATE_STORAGE_CLASS=false
PLATFORM_STORAGE_CLASS=cloudhost-expandable PLATFORM_STORAGE_CLASS=cloudhost-expandable
PLATFORM_CREATE_STORAGE_CLASS=true PLATFORM_CREATE_STORAGE_CLASS=true
PLATFORM_STORAGE_PROVISIONER=rancher.io/local-path PLATFORM_STORAGE_PROVISIONER=rancher.io/local-path
@@ -12,6 +12,9 @@ metadata:
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- if .Values.storageClass }}
storageClassName: {{ .Values.storageClass | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.storage }} storage: {{ .Values.storage }}
@@ -1,7 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ include "cloudhost-logging.namespace" . }}
labels:
app.kubernetes.io/managed-by: cloudhost
app.kubernetes.io/instance: {{ .Release.Name }}
@@ -7,6 +7,8 @@ kibanaSystemPassword: ""
clusterName: cloudhost-logs clusterName: cloudhost-logs
storage: 50Gi storage: 50Gi
# Leave empty to use the cluster default StorageClass (e.g. local-path on k3s).
storageClass: ""
# Official Elastic images; require docker.elastic.co DNS + outbound HTTPS from nodes. # Official Elastic images; require docker.elastic.co DNS + outbound HTTPS from nodes.
# If pull fails with "lookup docker.elastic.co: Try again", mirror to your registry and override here. # If pull fails with "lookup docker.elastic.co: Try again", mirror to your registry and override here.
@@ -38,7 +38,8 @@ ingress:
backend: backend:
env: env:
PLATFORM_DOMAIN: apps.example.com PLATFORM_DOMAIN: apps.example.com
REGISTRY_PULL_URL: "10.0.0.50:30500" REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000
REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000
migrations: migrations:
enabled: true enabled: true
+11 -20
View File
@@ -9,6 +9,7 @@ import { promisify } from 'util';
import { Application } from '../applications/entities/application.entity'; import { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums'; import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service'; import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -48,6 +49,7 @@ export class BuildService {
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private clustersService: ClustersService, private clustersService: ClustersService,
private registryService: RegistryService,
) {} ) {}
private beginBuildSession(deploymentId: string): void { private beginBuildSession(deploymentId: string): void {
@@ -213,16 +215,12 @@ export class BuildService {
* Returns { imageUri, buildLog } — the full image URI and the build logs. * Returns { imageUri, buildLog } — the full image URI and the build logs.
*/ */
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> { async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
// Internal registry (used by Kaniko inside K8s for pushing) const registryUrl = this.registryService.getRegistryUrl();
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000'; const buildNamespace = this.registryService.getBuildNamespace();
// External registry URL (used by kubelet for pulling — NodePort or external)
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const tag = `${Date.now()}`; const tag = `${Date.now()}`;
const pushImageUri = `${internalRegistryUrl}/${app.userId}/${app.name}:${tag}`; const imageUri = this.registryService.buildImageReference(app.userId, app.name, tag);
const pullImageUri = `${pullRegistryUrl}/${app.userId}/${app.name}:${tag}`;
this.logger.log(`Starting image build for ${app.name}push: ${pushImageUri}, pull: ${pullImageUri}`); this.logger.log(`Starting image build for ${app.name}${imageUri}`);
if (deploymentId) { if (deploymentId) {
this.beginBuildSession(deploymentId); this.beginBuildSession(deploymentId);
@@ -295,9 +293,9 @@ export class BuildService {
const kanikoArgs = [ const kanikoArgs = [
'--dockerfile=/workspace/Dockerfile', '--dockerfile=/workspace/Dockerfile',
'--context=dir:///workspace/source', '--context=dir:///workspace/source',
`--destination=${pushImageUri}`, `--destination=${imageUri}`,
'--cache=true', '--cache=true',
`--cache-repo=${internalRegistryUrl}/${app.userId}/cache`, `--cache-repo=${registryUrl}/${app.userId}/cache`,
'--insecure', '--insecure',
'--skip-tls-verify', '--skip-tls-verify',
'--single-snapshot', '--single-snapshot',
@@ -484,8 +482,8 @@ export class BuildService {
buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!); buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
} catch {} } catch {}
this.logger.log(`Build completed successfully: ${pullImageUri}`); this.logger.log(`Build completed successfully: ${imageUri}`);
return { imageUri: pullImageUri, buildLog }; return { imageUri, buildLog };
} catch (error: any) { } catch (error: any) {
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') { if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
throw error; throw error;
@@ -826,18 +824,11 @@ export class BuildService {
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) { if (err.statusCode === 404 || err.body?.code === 404) {
this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`); this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`);
const registryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
// Create a docker config that allows insecure push (for internal registry)
const dockerConfig = JSON.stringify({
auths: {
[registryUrl]: { auth: '' },
},
});
await coreApi.createNamespacedSecret(namespace, { await coreApi.createNamespacedSecret(namespace, {
metadata: { name: registrySecretName, namespace }, metadata: { name: registrySecretName, namespace },
type: 'kubernetes.io/dockerconfigjson', type: 'kubernetes.io/dockerconfigjson',
data: { data: {
'.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'), '.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
}, },
}); });
} else { } else {
+26 -25
View File
@@ -21,6 +21,7 @@ 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 { ElasticsearchService } from '../kubernetes/elasticsearch.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';
@@ -54,6 +55,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
private configService: ConfigService, private configService: ConfigService,
@Inject(forwardRef(() => ElasticsearchService)) @Inject(forwardRef(() => ElasticsearchService))
private elasticsearchService: ElasticsearchService, private elasticsearchService: ElasticsearchService,
@Inject(forwardRef(() => RegistryService))
private registryService: RegistryService,
) {} ) {}
onModuleInit(): void { onModuleInit(): void {
@@ -835,9 +838,9 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
const coreApi = kc.makeApiClient(k8s.CoreV1Api); const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const appsApi = kc.makeApiClient(k8s.AppsV1Api); const appsApi = kc.makeApiClient(k8s.AppsV1Api);
const buildNs = this.configService.get<string>('build.namespace') || 'cloudhost-builds'; const buildNs = this.registryService.getBuildNamespace();
const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder'; const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder';
const registryUrl = this.configService.get<string>('registry.url') || `registry.${buildNs}.svc.cluster.local:5000`; const registryUrl = this.registryService.getRegistryUrl();
this.logger.log(`Bootstrapping cluster — namespace: ${buildNs}`); this.logger.log(`Bootstrapping cluster — namespace: ${buildNs}`);
@@ -908,9 +911,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
name: 'registry', name: 'registry',
image: 'registry:2', image: 'registry:2',
ports: [{ containerPort: 5000 }], ports: [{ containerPort: 5000 }],
env: [ env: [{ name: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'true' }],
{ name: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'true' },
],
volumeMounts: [{ volumeMounts: [{
name: 'registry-data', name: 'registry-data',
mountPath: '/var/lib/registry', mountPath: '/var/lib/registry',
@@ -934,7 +935,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
} }
} }
// ── 5. Registry ClusterIP Service (for Kaniko to push) ──────── // ── 5. Registry ClusterIP Service (Kaniko push + app pull) ───
const registrySvcName = 'registry'; const registrySvcName = 'registry';
try { try {
await coreApi.readNamespacedService(registrySvcName, buildNs); await coreApi.readNamespacedService(registrySvcName, buildNs);
@@ -955,7 +956,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
} }
} }
// ── 6. Registry NodePort Service (for kubelet to pull) ─────── // ── 6. Registry NodePort Service (optional host access :30500)
const registryNodePort = 30500;
const registryNodePortName = 'registry-nodeport'; const registryNodePortName = 'registry-nodeport';
try { try {
await coreApi.readNamespacedService(registryNodePortName, buildNs); await coreApi.readNamespacedService(registryNodePortName, buildNs);
@@ -967,10 +969,15 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
spec: { spec: {
type: 'NodePort', type: 'NodePort',
selector: { app: 'registry' }, selector: { app: 'registry' },
ports: [{ port: 5000, targetPort: 5000 as any, nodePort: 30500, protocol: 'TCP' }], ports: [{
port: 5000,
targetPort: 5000 as any,
nodePort: registryNodePort,
protocol: 'TCP',
}],
}, },
}); });
this.logger.log(`Created Registry NodePort Service (30500 → 5000)`); this.logger.log(`Created Registry NodePort Service (${registryNodePort} → 5000)`);
} else { } else {
throw err; throw err;
} }
@@ -978,33 +985,27 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
// ── 7. registry-credentials Secret (docker config for Kaniko) ─ // ── 7. registry-credentials Secret (docker config for Kaniko) ─
const registrySecretName = 'registry-credentials'; const registrySecretName = 'registry-credentials';
try { const dockerConfig = this.registryService.buildDockerConfigJson();
await coreApi.readNamespacedSecret(registrySecretName, buildNs); const kanikoRegistrySecret: k8s.V1Secret = {
this.logger.log(`Secret "${registrySecretName}" already exists`);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
// Parse registry host (without port path) for the docker config
const dockerConfig = JSON.stringify({
auths: {
[registryUrl]: { auth: '' },
[`registry.${buildNs}.svc.cluster.local:5000`]: { auth: '' },
'localhost:30500': { auth: '' },
},
});
await coreApi.createNamespacedSecret(buildNs, {
metadata: { name: registrySecretName, namespace: buildNs }, metadata: { name: registrySecretName, namespace: buildNs },
type: 'kubernetes.io/dockerconfigjson', type: 'kubernetes.io/dockerconfigjson',
data: { data: {
'.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'), '.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'),
}, },
}); };
try {
await coreApi.readNamespacedSecret(registrySecretName, buildNs);
await coreApi.replaceNamespacedSecret(registrySecretName, buildNs, kanikoRegistrySecret);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespacedSecret(buildNs, kanikoRegistrySecret);
this.logger.log(`Created registry-credentials Secret`); this.logger.log(`Created registry-credentials Secret`);
} else { } else {
throw err; throw err;
} }
} }
this.logger.log(`✅ Cluster bootstrap complete — build infrastructure ready`); this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`);
} }
private parseCpuToMillicores(cpu: string): number { private parseCpuToMillicores(cpu: string): number {
+4 -1
View File
@@ -23,8 +23,9 @@ export default () => ({
}, },
registry: { registry: {
/** In-cluster registry — Kaniko push and app image pull (same host). */
url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500', pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
username: process.env.REGISTRY_USERNAME || 'admin', username: process.env.REGISTRY_USERNAME || 'admin',
password: process.env.REGISTRY_PASSWORD || '', password: process.env.REGISTRY_PASSWORD || '',
}, },
@@ -49,6 +50,8 @@ export default () => ({
process.env.LOGGING_ELASTICSEARCH_IMAGE || process.env.LOGGING_ELASTICSEARCH_IMAGE ||
'docker.elastic.co/elasticsearch/elasticsearch:8.12.0', 'docker.elastic.co/elasticsearch/elasticsearch:8.12.0',
kibana: process.env.LOGGING_KIBANA_IMAGE || 'docker.elastic.co/kibana/kibana:8.12.0', kibana: process.env.LOGGING_KIBANA_IMAGE || 'docker.elastic.co/kibana/kibana:8.12.0',
busybox: process.env.LOGGING_BUSYBOX_IMAGE || 'busybox:1.36',
curl: process.env.LOGGING_CURL_IMAGE || 'curlimages/curl:8.5.0',
}, },
}, },
@@ -19,13 +19,14 @@ export class ElasticsearchController {
@ApiQuery({ name: 'clusterId', required: false, description: 'Target cluster ID' }) @ApiQuery({ name: 'clusterId', required: false, description: 'Target cluster ID' })
@ApiResponse({ status: 200, description: 'Elasticsearch status' }) @ApiResponse({ status: 200, description: 'Elasticsearch status' })
async getStatus(@Query('clusterId') clusterId?: string) { async getStatus(@Query('clusterId') clusterId?: string) {
const isDeployed = await this.esService.isDeployed(clusterId); const state = await this.esService.getDeployState(clusterId);
const health = isDeployed ? await this.esService.getHealth(clusterId) : null;
const connectionInfo = this.esService.getConnectionInfo(); const connectionInfo = this.esService.getConnectionInfo();
return { return {
deployed: isDeployed, deployed: state.status === 'ready',
health, deployStatus: state.status,
helmReleaseStatus: state.helmReleaseStatus,
health: state.health,
namespace: 'logging', namespace: 'logging',
elasticsearch: { elasticsearch: {
host: connectionInfo.host, host: connectionInfo.host,
@@ -41,28 +42,38 @@ export class ElasticsearchController {
} }
@Post('deploy') @Post('deploy')
@ApiOperation({ summary: 'Deploy central Elasticsearch + Kibana stack' }) @ApiOperation({ summary: 'Deploy or repair central Elasticsearch + Kibana stack' })
@ApiQuery({ name: 'clusterId', required: false, description: 'Target cluster ID' }) @ApiQuery({ name: 'clusterId', required: false, description: 'Target cluster ID' })
@ApiResponse({ status: 201, description: 'Elasticsearch deployed successfully' }) @ApiResponse({ status: 201, description: 'Elasticsearch deployed successfully' })
async deploy(@Query('clusterId') clusterId?: string) { async deploy(@Query('clusterId') clusterId?: string) {
const alreadyDeployed = await this.esService.isDeployed(clusterId); const before = await this.esService.getDeployState(clusterId);
if (alreadyDeployed) { if (before.status === 'ready') {
const health = await this.esService.getHealth(clusterId);
return { return {
success: true, success: true,
message: 'Elasticsearch is already deployed', message: 'Elasticsearch is already deployed and healthy',
status: 'existing', status: 'existing',
health, deployStatus: before.status,
health: before.health,
}; };
} }
const result = await this.esService.deploy(clusterId); const result = await this.esService.deploy(clusterId);
const after = await this.esService.getDeployState(clusterId);
const status =
before.status === 'not_installed'
? 'created'
: 'repaired';
return { return {
success: true, success: true,
message: 'Elasticsearch and Kibana deployed successfully. Please wait 2-3 minutes for pods to be ready.', message: result.deploying
status: 'created', ? 'Logging stack installed. Pods are starting — allow 25 minutes for Elasticsearch and Kibana to become ready.'
: 'Elasticsearch and Kibana are ready.',
status,
deployStatus: after.status,
health: after.health,
credentials: { credentials: {
username: 'elastic', username: 'elastic',
password: result.esPassword, password: result.esPassword,
+156 -17
View File
@@ -59,6 +59,21 @@ export interface LogStatsResult {
period: string; period: string;
} }
export type LoggingDeployStatus = 'not_installed' | 'installing' | 'failed' | 'ready';
export interface LoggingDeployState {
status: LoggingDeployStatus;
helmReleaseStatus?: string | null;
health: {
status: string;
clusterName: string;
numberOfNodes: number;
activePrimaryShards: number;
activeShards: number;
kibanaReady: boolean;
} | null;
}
/** /**
* Central Elasticsearch management service. * Central Elasticsearch management service.
* Deploys a shared Elasticsearch + Kibana stack in a dedicated namespace * Deploys a shared Elasticsearch + Kibana stack in a dedicated namespace
@@ -358,19 +373,126 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
/** /**
* Check if central Elasticsearch is deployed in a cluster * Check if central Elasticsearch is deployed and healthy in a cluster.
*/ */
async isDeployed(clusterId?: string): Promise<boolean> { async isDeployed(clusterId?: string): Promise<boolean> {
const { appsApi } = await this.getK8sClients(clusterId); const state = await this.getDeployState(clusterId);
return state.status === 'ready';
}
/**
* Detailed logging stack state (Helm release + pod readiness).
*/
async getDeployState(clusterId?: string): Promise<LoggingDeployState> {
const { cluster } = await this.getK8sClients(clusterId);
const helmStatus = await this.helmService.status(
LOGGING_HELM_RELEASE,
LOGGING_HELM_NAMESPACE,
cluster.kubeconfig,
);
let hasEsWorkload = false;
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
await appsApi.readNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE);
hasEsWorkload = true;
} catch {
hasEsWorkload = false;
}
if (!helmStatus && !hasEsWorkload) {
return { status: 'not_installed', helmReleaseStatus: null, health: null };
}
const health = await this.getHealth(clusterId);
const helmReleaseStatus = helmStatus?.status || null;
if (health?.status === 'green') {
return { status: 'ready', helmReleaseStatus, health };
}
if (helmReleaseStatus === 'failed' || helmReleaseStatus === 'pending-install') {
return { status: 'failed', helmReleaseStatus, health };
}
if (hasEsWorkload || helmReleaseStatus === 'deployed') {
return { status: 'installing', helmReleaseStatus, health };
}
return { status: 'installing', helmReleaseStatus, health };
}
private resolveLoggingStorageClass(): string | undefined {
const storageClass = this.configService.get<string>('platform.storageClass');
return storageClass?.trim() || undefined;
}
/**
* Ensure platform StorageClass exists before Helm creates PVCs (same as app deploy chart).
*/
private async ensureClusterStorageClass(kubeconfig: string): Promise<void> {
const storageClass = this.resolveLoggingStorageClass();
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
if (!storageClass || !createStorageClass) {
return;
}
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
const provisioner =
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
try { try {
await appsApi.readNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE); await storageApi.readStorageClass(storageClass);
return true; return;
} catch { } catch (err: any) {
return false; if (err.statusCode !== 404 && err.body?.code !== 404) {
throw err;
} }
} }
await storageApi.createStorageClass({
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
metadata: { name: storageClass },
provisioner,
allowVolumeExpansion: true,
reclaimPolicy: 'Delete',
volumeBindingMode: 'WaitForFirstConsumer',
});
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
}
private buildLoggingHelmValues(): {
elasticPassword: string;
fluentbitPassword: string;
kibanaSystemPassword: string;
storageClass?: string;
images: {
elasticsearch?: string;
kibana?: string;
busybox?: string;
curl?: string;
};
} {
const storageClass = this.resolveLoggingStorageClass();
return {
elasticPassword: this.ELASTIC_PASSWORD,
fluentbitPassword: this.FLUENTBIT_PASSWORD,
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
...(storageClass ? { storageClass } : {}),
images: {
elasticsearch: this.configService.get<string>('elasticsearch.images.elasticsearch'),
kibana: this.configService.get<string>('elasticsearch.images.kibana'),
busybox: this.configService.get<string>('elasticsearch.images.busybox'),
curl: this.configService.get<string>('elasticsearch.images.curl'),
},
};
}
/** /**
* Get Elasticsearch health and status * Get Elasticsearch health and status
*/ */
@@ -432,28 +554,45 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
/** /**
* Deploy central Elasticsearch + Kibana stack via Helm. * Deploy or repair central Elasticsearch + Kibana stack via Helm.
* Always runs upgrade --install; does not wait for pods (avoids timeout errors on slow image pulls).
*/ */
async deploy(clusterId?: string): Promise<{ esPassword: string; kibanaUrl: string }> { async deploy(clusterId?: string): Promise<{ esPassword: string; kibanaUrl: string; deploying: boolean }> {
const { cluster } = await this.getK8sClients(clusterId); const { cluster } = await this.getK8sClients(clusterId);
await this.helmService.installLoggingStack(cluster.kubeconfig, { await this.ensureClusterStorageClass(cluster.kubeconfig);
elasticPassword: this.ELASTIC_PASSWORD,
fluentbitPassword: this.FLUENTBIT_PASSWORD, const helmValues = this.buildLoggingHelmValues();
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
images: { try {
elasticsearch: this.configService.get<string>('elasticsearch.images.elasticsearch'), await this.helmService.installLoggingStack(cluster.kubeconfig, helmValues, {
kibana: this.configService.get<string>('elasticsearch.images.kibana'), wait: false,
}, timeout: '10m',
}); });
} catch (error: any) {
const release = await this.helmService.status(
LOGGING_HELM_RELEASE,
LOGGING_HELM_NAMESPACE,
cluster.kubeconfig,
);
if (!release) {
throw error;
}
this.logger.warn(
`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`,
);
}
this.logger.log( this.logger.log(
`Central logging stack deployed via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`, `Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`,
); );
const state = await this.getDeployState(clusterId);
return { return {
esPassword: this.ELASTIC_PASSWORD, esPassword: this.ELASTIC_PASSWORD,
kibanaUrl: `http://kibana.${this.ES_NAMESPACE}.svc.cluster.local:5601`, kibanaUrl: `http://kibana.${this.ES_NAMESPACE}.svc.cluster.local:5601`,
deploying: state.status !== 'ready',
}; };
} }
+26 -17
View File
@@ -104,30 +104,39 @@ export class HelmService {
elasticPassword: string; elasticPassword: string;
fluentbitPassword: string; fluentbitPassword: string;
kibanaSystemPassword: string; kibanaSystemPassword: string;
images?: { elasticsearch?: string; kibana?: string }; storageClass?: string;
images?: {
elasticsearch?: string;
kibana?: string;
busybox?: string;
curl?: string;
};
}, },
options: HelmInstallOptions = { wait: false, timeout: '10m' },
): Promise<{ stdout: string; stderr: string }> { ): Promise<{ stdout: string; stderr: string }> {
const chartValues: Record<string, unknown> = {
elasticPassword: values.elasticPassword,
fluentbitPassword: values.fluentbitPassword,
kibanaSystemPassword: values.kibanaSystemPassword,
};
if (values.storageClass) {
chartValues.storageClass = values.storageClass;
}
if (values.images) {
chartValues.images = {
...(values.images.elasticsearch ? { elasticsearch: values.images.elasticsearch } : {}),
...(values.images.kibana ? { kibana: values.images.kibana } : {}),
...(values.images.busybox ? { busybox: values.images.busybox } : {}),
...(values.images.curl ? { curl: values.images.curl } : {}),
};
}
return this.installOrUpgradeFromChart( return this.installOrUpgradeFromChart(
'cloudhost-logging', 'cloudhost-logging',
LOGGING_HELM_RELEASE, LOGGING_HELM_RELEASE,
LOGGING_HELM_NAMESPACE, LOGGING_HELM_NAMESPACE,
{ chartValues,
elasticPassword: values.elasticPassword,
fluentbitPassword: values.fluentbitPassword,
kibanaSystemPassword: values.kibanaSystemPassword,
...(values.images?.elasticsearch || values.images?.kibana
? {
images: {
...(values.images.elasticsearch
? { elasticsearch: values.images.elasticsearch }
: {}),
...(values.images.kibana ? { kibana: values.images.kibana } : {}),
},
}
: {}),
},
kubeconfig, kubeconfig,
{ wait: true, timeout: '10m' }, options,
); );
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { KubernetesService } from './kubernetes.service'; import { KubernetesService } from './kubernetes.service';
import { HelmService } from './helm.service'; import { HelmService } from './helm.service';
import { RegistryService } from './registry.service';
import { ElasticsearchService } from './elasticsearch.service'; import { ElasticsearchService } from './elasticsearch.service';
import { ElasticsearchController } from './elasticsearch.controller'; import { ElasticsearchController } from './elasticsearch.controller';
import { LogsController } from './logs.controller'; import { LogsController } from './logs.controller';
@@ -11,7 +12,7 @@ import { Application } from '../applications/entities/application.entity';
@Module({ @Module({
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application])], imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application])],
controllers: [ElasticsearchController, LogsController], controllers: [ElasticsearchController, LogsController],
providers: [KubernetesService, HelmService, ElasticsearchService], providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
exports: [KubernetesService, HelmService, ElasticsearchService], exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
}) })
export class KubernetesModule {} export class KubernetesModule {}
+60 -6
View File
@@ -18,6 +18,7 @@ import {
isManagedProductType, isManagedProductType,
} from '../common/enums'; } from '../common/enums';
import { HelmService } from './helm.service'; import { HelmService } from './helm.service';
import { RegistryService } from './registry.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -72,6 +73,7 @@ export class KubernetesService implements OnModuleInit {
private configService: ConfigService, private configService: ConfigService,
private clustersService: ClustersService, private clustersService: ClustersService,
private helmService: HelmService, private helmService: HelmService,
private registryService: RegistryService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -161,10 +163,45 @@ export class KubernetesService implements OnModuleInit {
return { storageClass, createStorageClass, storageProvisioner }; return { storageClass, createStorageClass, storageProvisioner };
} }
/** Create platform StorageClass on the target cluster when configured (Helm chart + K8s API fallback). */
private async ensurePlatformStorageClass(kubeconfig: string): Promise<void> {
const storageClass = this.configService.get<string>('platform.storageClass')?.trim();
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
if (!storageClass || !createStorageClass) {
return;
}
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
const provisioner =
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
try {
await storageApi.readStorageClass(storageClass);
return;
} catch (err: any) {
if (err.statusCode !== 404 && err.body?.code !== 404) {
throw err;
}
}
await storageApi.createStorageClass({
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
metadata: { name: storageClass },
provisioner,
allowVolumeExpansion: true,
reclaimPolicy: 'Delete',
volumeBindingMode: 'WaitForFirstConsumer',
});
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
}
/** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */ /** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */
private buildManagedHelmValues(app: Application): Record<string, any> { private buildManagedHelmValues(app: Application): Record<string, any> {
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = `user-${app.userId.split('-')[0]}`;
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500'; const pullRegistryUrl = this.registryService.getRegistryUrl();
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const productType = app.productType; const productType = app.productType;
@@ -244,7 +281,7 @@ export class KubernetesService implements OnModuleInit {
private buildHelmValues(app: Application, imageUri: string): Record<string, any> { private buildHelmValues(app: Application, imageUri: string): Record<string, any> {
const domain = this.configService.get('platform.domain'); const domain = this.configService.get('platform.domain');
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500'; const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS; const isWordPress = app.runtime === AppRuntime.WORDPRESS;
const hasDb = app.databaseType !== DatabaseType.NONE; const hasDb = app.databaseType !== DatabaseType.NONE;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
@@ -332,14 +369,20 @@ export class KubernetesService implements OnModuleInit {
if (isManagedProductType(app.productType)) { if (isManagedProductType(app.productType)) {
return this.deployManagedService(app); return this.deployManagedService(app);
} }
const workloadImage = this.registryService.normalizeImageReference(imageUri);
if (workloadImage !== imageUri) {
this.logger.log(`Using in-cluster registry image for ${app.name}: ${workloadImage}`);
}
// Try Helm first, fall back to direct K8s API if Helm is unavailable // Try Helm first, fall back to direct K8s API if Helm is unavailable
try { try {
return await this.deployViaHelm(app, imageUri); return await this.deployViaHelm(app, workloadImage);
} catch (helmError: any) { } catch (helmError: any) {
this.logger.warn( this.logger.warn(
`Helm deploy failed for ${app.name}, falling back to direct K8s API: ${helmError.message}`, `Helm deploy failed for ${app.name}, falling back to direct K8s API: ${helmError.message}`,
); );
return await this.deployViaK8sApi(app, imageUri); return await this.deployViaK8sApi(app, workloadImage);
} }
} }
@@ -399,7 +442,7 @@ export class KubernetesService implements OnModuleInit {
try { try {
const kubeconfig = await this.getKubeconfig(app.clusterId); const kubeconfig = await this.getKubeconfig(app.clusterId);
const imageUri = app.latestImageTag const imageUri = app.latestImageTag
? `${this.configService.get<string>('registry.pullUrl') || 'localhost:30500'}/${app.name}:${app.latestImageTag}` ? this.registryService.normalizeImageReference(app.latestImageTag)
: ''; : '';
const values = this.buildHelmValues(app, imageUri); const values = this.buildHelmValues(app, imageUri);
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig); await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
@@ -446,8 +489,11 @@ export class KubernetesService implements OnModuleInit {
private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> { private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId); const kubeconfig = await this.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const { coreApi } = await this.getK8sClient(app.clusterId);
const values = this.buildHelmValues(app, imageUri); const values = this.buildHelmValues(app, imageUri);
const namespace = values.app.namespace; const namespace = values.app.namespace as string;
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
const releaseName = app.name; const releaseName = app.name;
const result = await this.helmService.installOrUpgrade( const result = await this.helmService.installOrUpgrade(
@@ -463,6 +509,7 @@ export class KubernetesService implements OnModuleInit {
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> { private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId); const kubeconfig = await this.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const values = this.buildManagedHelmValues(app); const values = this.buildManagedHelmValues(app);
const namespace = values.app.namespace; const namespace = values.app.namespace;
const releaseName = app.name; const releaseName = app.name;
@@ -482,6 +529,8 @@ export class KubernetesService implements OnModuleInit {
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> { private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId); const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const kubeconfig = await this.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = `user-${app.userId.split('-')[0]}`;
const context: ManifestContext = { const context: ManifestContext = {
appName: app.name, appName: app.name,
@@ -546,6 +595,8 @@ export class KubernetesService implements OnModuleInit {
return this.deployManagedViaK8sApi(app); return this.deployManagedViaK8sApi(app);
} }
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const kubeconfig = await this.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const domain = this.configService.get('platform.domain'); const domain = this.configService.get('platform.domain');
const context: ManifestContext = { const context: ManifestContext = {
@@ -585,6 +636,9 @@ export class KubernetesService implements OnModuleInit {
// 1. Ensure namespace exists // 1. Ensure namespace exists
await this.ensureNamespace(coreApi, context.namespace); await this.ensureNamespace(coreApi, context.namespace);
// 1b. Image pull secret for in-cluster registry
await this.registryService.ensureRegistryPullSecret(coreApi, context.namespace);
// 2. Create/Update secrets for env vars // 2. Create/Update secrets for env vars
if (Object.keys(context.envVars).length > 0) { if (Object.keys(context.envVars).length > 0) {
manifests.secret = await this.applySecret(coreApi, context); manifests.secret = await this.applySecret(coreApi, context);
+110
View File
@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
export interface ParsedImageReference {
repository: string;
tag: string;
}
/**
* In-cluster Docker Registry v2 (ClusterIP).
* Kaniko pushes and application workloads pull from the same registry URL.
*/
@Injectable()
export class RegistryService {
private readonly logger = new Logger(RegistryService.name);
constructor(private readonly configService: ConfigService) {}
getBuildNamespace(): string {
return this.configService.get<string>('build.namespace') || 'cloudhost-builds';
}
/** Registry host:port used for build push and app image pull. */
getRegistryUrl(): string {
const buildNs = this.getBuildNamespace();
const url =
this.configService.get<string>('registry.pullUrl') ||
this.configService.get<string>('registry.url') ||
`registry.${buildNs}.svc.cluster.local:5000`;
return url.replace(/^https?:\/\//, '');
}
getRegistryCredentials(): { username: string; password: string } {
return {
username: this.configService.get<string>('registry.username') || 'admin',
password: this.configService.get<string>('registry.password') || '',
};
}
buildImageReference(userId: string, appName: string, tag: string): string {
return `${this.getRegistryUrl()}/${userId}/${appName}:${tag}`;
}
parseImageReference(imageRef: string): ParsedImageReference {
const normalized = imageRef.replace(/^https?:\/\//, '');
const slashIdx = normalized.indexOf('/');
if (slashIdx === -1) {
throw new Error(`Invalid image reference (missing repository path): ${imageRef}`);
}
const rest = normalized.slice(slashIdx + 1);
const tagIdx = rest.lastIndexOf(':');
if (tagIdx === -1) {
throw new Error(`Invalid image reference (missing tag): ${imageRef}`);
}
return {
repository: rest.slice(0, tagIdx),
tag: rest.slice(tagIdx + 1),
};
}
/** Re-point any stored image (e.g. legacy external host) to the in-cluster registry. */
normalizeImageReference(imageRef: string): string {
const { repository, tag } = this.parseImageReference(imageRef);
return `${this.getRegistryUrl()}/${repository}:${tag}`;
}
buildDockerConfigJson(): string {
const { username, password } = this.getRegistryCredentials();
const auth =
username && password
? Buffer.from(`${username}:${password}`).toString('base64')
: '';
const host = this.getRegistryUrl();
return JSON.stringify({
auths: {
[host]: { auth },
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: { auth },
},
});
}
async ensureRegistryPullSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
const secretName = 'registry-pull-secret';
const secret: k8s.V1Secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: secretName,
namespace,
labels: { 'app.kubernetes.io/managed-by': 'cloudhost' },
},
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.buildDockerConfigJson()).toString('base64'),
},
};
try {
await coreApi.replaceNamespacedSecret(secretName, namespace, secret);
} catch (err: any) {
if (err.statusCode === 404 || err.body?.code === 404) {
await coreApi.createNamespacedSecret(namespace, secret);
this.logger.log(`Created ${secretName} in ${namespace}`);
} else {
throw err;
}
}
}
}
@@ -120,28 +120,55 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
); );
} }
function CentralLoggingPanel() { function apiErrorMessage(err: unknown, fallback: string): string {
const e = err as { response?: { data?: { message?: string | string[] } } };
const msg = e?.response?.data?.message;
if (Array.isArray(msg)) return msg.join(', ');
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
function CentralLoggingPanel({
clusters,
selectedClusterId,
onSelectCluster,
}: {
clusters: Cluster[];
selectedClusterId: string | null;
onSelectCluster: (id: string) => void;
}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
const { data: status, isLoading } = useQuery({ const { data: status, isLoading } = useQuery({
queryKey: ['admin-elasticsearch-status'], queryKey: ['admin-elasticsearch-status', clusterId],
queryFn: () => api.get('/admin/elasticsearch/status').then((r) => r.data), queryFn: () =>
api.get('/admin/elasticsearch/status', { params: clusterId ? { clusterId } : {} }).then((r) => r.data),
enabled: !!clusterId,
}); });
const deployMutation = useMutation({ const deployMutation = useMutation({
mutationFn: () => api.post('/admin/elasticsearch/deploy'), mutationFn: (targetClusterId?: string) =>
onSuccess: () => { api.post('/admin/elasticsearch/deploy', null, {
params: targetClusterId ? { clusterId: targetClusterId } : {},
}),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] });
toast.success('Logging stack deployment started'); toast.success(res.data?.message || 'Logging stack deployment started');
}, },
onError: () => toast.error('Failed to deploy logging stack'), onError: (err) => toast.error(apiErrorMessage(err, 'Failed to deploy logging stack')),
}); });
const undeployMutation = useMutation({ const undeployMutation = useMutation({
mutationFn: () => api.delete('/admin/elasticsearch/undeploy'), mutationFn: (targetClusterId?: string) =>
api.delete('/admin/elasticsearch/undeploy', {
params: targetClusterId ? { clusterId: targetClusterId } : {},
}),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] }); queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] });
toast.success('Logging stack removed'); 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'; const kibanaCmd = 'kubectl port-forward svc/kibana 5601:5601 -n logging';
@@ -158,21 +185,34 @@ function CentralLoggingPanel() {
End users never get Kibana access staff use port-forward. End users never get Kibana access staff use port-forward.
</p> </p>
</div> </div>
<div className="flex gap-2"> <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 ? ( {!status?.deployed ? (
<button <button
type="button" type="button"
onClick={() => deployMutation.mutate()} onClick={() => deployMutation.mutate(clusterId)}
disabled={deployMutation.isPending} disabled={deployMutation.isPending || !clusterId}
className="btn-primary text-sm" className="btn-primary text-sm"
> >
{deployMutation.isPending ? 'Deploying…' : 'Deploy stack'} {deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => undeployMutation.mutate()} onClick={() => undeployMutation.mutate(clusterId)}
disabled={undeployMutation.isPending} disabled={undeployMutation.isPending || !clusterId}
className="btn-secondary text-sm text-red-600" className="btn-secondary text-sm text-red-600"
> >
Remove stack Remove stack
@@ -206,20 +246,65 @@ function CentralLoggingPanel() {
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm text-amber-700"> <div className="text-sm text-amber-700 space-y-1">
Not deployed on the default cluster. New clusters install this automatically; use Deploy for existing clusters. <p>
Not ready on this cluster
{status?.deployStatus ? ` (${status.deployStatus}${status.helmReleaseStatus ? ` · helm: ${status.helmReleaseStatus}` : ''})` : ''}.
</p> </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>
); );
} }
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();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null); const [testingId, setTestingId] = useState<string | null>(null);
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set()); const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
const [loggingClusterId, setLoggingClusterId] = useState<string | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
description: '', description: '',
@@ -308,7 +393,11 @@ export default function AdminClustersPage() {
</button> </button>
</div> </div>
<CentralLoggingPanel /> <CentralLoggingPanel
clusters={clusters}
selectedClusterId={loggingClusterId}
onSelectCluster={setLoggingClusterId}
/>
{showForm && ( {showForm && (
<div className="card space-y-4 animate-slide-up"> <div className="card space-y-4 animate-slide-up">
@@ -477,6 +566,7 @@ 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}
-14
View File
@@ -1,14 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@3fase.ir
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: traefik
-26
View File
@@ -1,26 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: registry-public
namespace: cloudhost-builds
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/service.serverstransport: cloudhost-builds-registry-transport@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- repo.3fase.ir
secretName: repo-3fase-ir-tls
rules:
- host: repo.3fase.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: registry
port:
number: 5000
@@ -1,22 +0,0 @@
# Patch registry Deployment for reverse-proxy (Traefik + HTTPS)
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: cloudhost-builds
spec:
template:
spec:
containers:
- name: registry
env:
- name: REGISTRY_STORAGE_DELETE_ENABLED
value: "true"
- name: REGISTRY_HTTP_RELATIVEURLS
value: "true"
- name: REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin
value: '["*"]'
- name: REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods
value: '["HEAD","GET","OPTIONS","DELETE"]'
- name: REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers
value: '["Authorization","Accept","Cache-Control"]'
@@ -1,23 +0,0 @@
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: registry-transport
namespace: cloudhost-builds
spec:
forwardingTimeouts:
dialTimeout: 30s
responseHeaderTimeout: 600s
idleConnTimeout: 600s
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: registry-buffering
namespace: cloudhost-builds
spec:
buffering:
maxRequestBodyBytes: 0
memRequestBodyBytes: 20971520
maxResponseBodyBytes: 0
memResponseBodyBytes: 20971520
retryExpression: "IsNetworkError() && Attempts() < 2"
@@ -1,14 +0,0 @@
# Merge with existing traefik helm values (helm upgrade traefik -f traefik-timeout-values.yaml)
ports:
web:
transport:
respondingTimeouts:
readTimeout: "0s"
writeTimeout: "0s"
idleTimeout: "1800s"
websecure:
transport:
respondingTimeouts:
readTimeout: "0s"
writeTimeout: "0s"
idleTimeout: "1800s"