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:
@@ -2,6 +2,7 @@
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
@@ -19,12 +20,9 @@ JWT_REFRESH_EXPIRES_IN=7d
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Container Registry (same Docker Registry v2, two hostnames)
|
||||
# Internal — Kaniko/build pods push here (ClusterIP, HTTP, fast)
|
||||
# In-cluster Docker Registry (Kaniko push + app image pull — same URL)
|
||||
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=repo.3fase.ir
|
||||
# REGISTRY_PULL_URL=10.0.0.50:30500
|
||||
# REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000
|
||||
REGISTRY_USERNAME=admin
|
||||
REGISTRY_PASSWORD=registry_secret
|
||||
|
||||
@@ -47,6 +45,9 @@ BUILD_SERVICE_ACCOUNT=kaniko-builder
|
||||
PLATFORM_DOMAIN=apps.cloudhost.local
|
||||
UPLOAD_DIR=./uploads
|
||||
# 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_CREATE_STORAGE_CLASS=true
|
||||
PLATFORM_STORAGE_PROVISIONER=rancher.io/local-path
|
||||
|
||||
@@ -12,6 +12,9 @@ metadata:
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.storageClass }}
|
||||
storageClassName: {{ .Values.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
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
|
||||
|
||||
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.
|
||||
# If pull fails with "lookup docker.elastic.co: Try again", mirror to your registry and override here.
|
||||
|
||||
@@ -38,7 +38,8 @@ ingress:
|
||||
backend:
|
||||
env:
|
||||
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:
|
||||
enabled: true
|
||||
|
||||
@@ -9,6 +9,7 @@ import { promisify } from 'util';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -48,6 +49,7 @@ export class BuildService {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private clustersService: ClustersService,
|
||||
private registryService: RegistryService,
|
||||
) {}
|
||||
|
||||
private beginBuildSession(deploymentId: string): void {
|
||||
@@ -213,16 +215,12 @@ export class BuildService {
|
||||
* Returns { imageUri, buildLog } — the full image URI and the build logs.
|
||||
*/
|
||||
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
|
||||
// Internal registry (used by Kaniko inside K8s for pushing)
|
||||
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
||||
// 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 registryUrl = this.registryService.getRegistryUrl();
|
||||
const buildNamespace = this.registryService.getBuildNamespace();
|
||||
const tag = `${Date.now()}`;
|
||||
const pushImageUri = `${internalRegistryUrl}/${app.userId}/${app.name}:${tag}`;
|
||||
const pullImageUri = `${pullRegistryUrl}/${app.userId}/${app.name}:${tag}`;
|
||||
const imageUri = this.registryService.buildImageReference(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) {
|
||||
this.beginBuildSession(deploymentId);
|
||||
@@ -295,9 +293,9 @@ export class BuildService {
|
||||
const kanikoArgs = [
|
||||
'--dockerfile=/workspace/Dockerfile',
|
||||
'--context=dir:///workspace/source',
|
||||
`--destination=${pushImageUri}`,
|
||||
`--destination=${imageUri}`,
|
||||
'--cache=true',
|
||||
`--cache-repo=${internalRegistryUrl}/${app.userId}/cache`,
|
||||
`--cache-repo=${registryUrl}/${app.userId}/cache`,
|
||||
'--insecure',
|
||||
'--skip-tls-verify',
|
||||
'--single-snapshot',
|
||||
@@ -484,8 +482,8 @@ export class BuildService {
|
||||
buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
|
||||
} catch {}
|
||||
|
||||
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
||||
return { imageUri: pullImageUri, buildLog };
|
||||
this.logger.log(`Build completed successfully: ${imageUri}`);
|
||||
return { imageUri, buildLog };
|
||||
} catch (error: any) {
|
||||
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
|
||||
throw error;
|
||||
@@ -826,18 +824,11 @@ export class BuildService {
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
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, {
|
||||
metadata: { name: registrySecretName, namespace },
|
||||
type: 'kubernetes.io/dockerconfigjson',
|
||||
data: {
|
||||
'.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'),
|
||||
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,7 @@ 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';
|
||||
|
||||
@@ -54,6 +55,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
private configService: ConfigService,
|
||||
@Inject(forwardRef(() => ElasticsearchService))
|
||||
private elasticsearchService: ElasticsearchService,
|
||||
@Inject(forwardRef(() => RegistryService))
|
||||
private registryService: RegistryService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -835,9 +838,9 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
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 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}`);
|
||||
|
||||
@@ -908,9 +911,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
name: 'registry',
|
||||
image: 'registry:2',
|
||||
ports: [{ containerPort: 5000 }],
|
||||
env: [
|
||||
{ name: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'true' },
|
||||
],
|
||||
env: [{ name: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'true' }],
|
||||
volumeMounts: [{
|
||||
name: 'registry-data',
|
||||
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';
|
||||
try {
|
||||
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';
|
||||
try {
|
||||
await coreApi.readNamespacedService(registryNodePortName, buildNs);
|
||||
@@ -967,10 +969,15 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
spec: {
|
||||
type: 'NodePort',
|
||||
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 {
|
||||
throw err;
|
||||
}
|
||||
@@ -978,33 +985,27 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ── 7. registry-credentials Secret (docker config for Kaniko) ─
|
||||
const registrySecretName = 'registry-credentials';
|
||||
const dockerConfig = this.registryService.buildDockerConfigJson();
|
||||
const kanikoRegistrySecret: k8s.V1Secret = {
|
||||
metadata: { name: registrySecretName, namespace: buildNs },
|
||||
type: 'kubernetes.io/dockerconfigjson',
|
||||
data: {
|
||||
'.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await coreApi.readNamespacedSecret(registrySecretName, buildNs);
|
||||
this.logger.log(`Secret "${registrySecretName}" already exists`);
|
||||
await coreApi.replaceNamespacedSecret(registrySecretName, buildNs, kanikoRegistrySecret);
|
||||
} 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 },
|
||||
type: 'kubernetes.io/dockerconfigjson',
|
||||
data: {
|
||||
'.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'),
|
||||
},
|
||||
});
|
||||
await coreApi.createNamespacedSecret(buildNs, kanikoRegistrySecret);
|
||||
this.logger.log(`Created registry-credentials Secret`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`✅ Cluster bootstrap complete — build infrastructure ready`);
|
||||
this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`);
|
||||
}
|
||||
|
||||
private parseCpuToMillicores(cpu: string): number {
|
||||
|
||||
@@ -23,8 +23,9 @@ export default () => ({
|
||||
},
|
||||
|
||||
registry: {
|
||||
/** In-cluster registry — Kaniko push and app image pull (same host). */
|
||||
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',
|
||||
password: process.env.REGISTRY_PASSWORD || '',
|
||||
},
|
||||
@@ -49,6 +50,8 @@ export default () => ({
|
||||
process.env.LOGGING_ELASTICSEARCH_IMAGE ||
|
||||
'docker.elastic.co/elasticsearch/elasticsearch: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' })
|
||||
@ApiResponse({ status: 200, description: 'Elasticsearch status' })
|
||||
async getStatus(@Query('clusterId') clusterId?: string) {
|
||||
const isDeployed = await this.esService.isDeployed(clusterId);
|
||||
const health = isDeployed ? await this.esService.getHealth(clusterId) : null;
|
||||
const state = await this.esService.getDeployState(clusterId);
|
||||
const connectionInfo = this.esService.getConnectionInfo();
|
||||
|
||||
return {
|
||||
deployed: isDeployed,
|
||||
health,
|
||||
deployed: state.status === 'ready',
|
||||
deployStatus: state.status,
|
||||
helmReleaseStatus: state.helmReleaseStatus,
|
||||
health: state.health,
|
||||
namespace: 'logging',
|
||||
elasticsearch: {
|
||||
host: connectionInfo.host,
|
||||
@@ -41,28 +42,38 @@ export class ElasticsearchController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiResponse({ status: 201, description: 'Elasticsearch deployed successfully' })
|
||||
async deploy(@Query('clusterId') clusterId?: string) {
|
||||
const alreadyDeployed = await this.esService.isDeployed(clusterId);
|
||||
const before = await this.esService.getDeployState(clusterId);
|
||||
|
||||
if (alreadyDeployed) {
|
||||
const health = await this.esService.getHealth(clusterId);
|
||||
if (before.status === 'ready') {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Elasticsearch is already deployed',
|
||||
message: 'Elasticsearch is already deployed and healthy',
|
||||
status: 'existing',
|
||||
health,
|
||||
deployStatus: before.status,
|
||||
health: before.health,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.esService.deploy(clusterId);
|
||||
const after = await this.esService.getDeployState(clusterId);
|
||||
|
||||
const status =
|
||||
before.status === 'not_installed'
|
||||
? 'created'
|
||||
: 'repaired';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Elasticsearch and Kibana deployed successfully. Please wait 2-3 minutes for pods to be ready.',
|
||||
status: 'created',
|
||||
message: result.deploying
|
||||
? 'Logging stack installed. Pods are starting — allow 2–5 minutes for Elasticsearch and Kibana to become ready.'
|
||||
: 'Elasticsearch and Kibana are ready.',
|
||||
status,
|
||||
deployStatus: after.status,
|
||||
health: after.health,
|
||||
credentials: {
|
||||
username: 'elastic',
|
||||
password: result.esPassword,
|
||||
|
||||
@@ -59,6 +59,21 @@ export interface LogStatsResult {
|
||||
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.
|
||||
* Deploys a shared Elasticsearch + Kibana stack in a dedicated namespace
|
||||
@@ -358,17 +373,124 @@ 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> {
|
||||
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 {
|
||||
await appsApi.readNamespacedStatefulSet(this.ES_NAME, this.ES_NAMESPACE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
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})`);
|
||||
}
|
||||
|
||||
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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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);
|
||||
|
||||
await this.helmService.installLoggingStack(cluster.kubeconfig, {
|
||||
elasticPassword: this.ELASTIC_PASSWORD,
|
||||
fluentbitPassword: this.FLUENTBIT_PASSWORD,
|
||||
kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD,
|
||||
images: {
|
||||
elasticsearch: this.configService.get<string>('elasticsearch.images.elasticsearch'),
|
||||
kibana: this.configService.get<string>('elasticsearch.images.kibana'),
|
||||
},
|
||||
});
|
||||
await this.ensureClusterStorageClass(cluster.kubeconfig);
|
||||
|
||||
const helmValues = this.buildLoggingHelmValues();
|
||||
|
||||
try {
|
||||
await this.helmService.installLoggingStack(cluster.kubeconfig, helmValues, {
|
||||
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(
|
||||
`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 {
|
||||
esPassword: this.ELASTIC_PASSWORD,
|
||||
kibanaUrl: `http://kibana.${this.ES_NAMESPACE}.svc.cluster.local:5601`,
|
||||
deploying: state.status !== 'ready',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -104,30 +104,39 @@ export class HelmService {
|
||||
elasticPassword: string;
|
||||
fluentbitPassword: 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 }> {
|
||||
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(
|
||||
'cloudhost-logging',
|
||||
LOGGING_HELM_RELEASE,
|
||||
LOGGING_HELM_NAMESPACE,
|
||||
{
|
||||
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 } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
chartValues,
|
||||
kubeconfig,
|
||||
{ wait: true, timeout: '10m' },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KubernetesService } from './kubernetes.service';
|
||||
import { HelmService } from './helm.service';
|
||||
import { RegistryService } from './registry.service';
|
||||
import { ElasticsearchService } from './elasticsearch.service';
|
||||
import { ElasticsearchController } from './elasticsearch.controller';
|
||||
import { LogsController } from './logs.controller';
|
||||
@@ -11,7 +12,7 @@ import { Application } from '../applications/entities/application.entity';
|
||||
@Module({
|
||||
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application])],
|
||||
controllers: [ElasticsearchController, LogsController],
|
||||
providers: [KubernetesService, HelmService, ElasticsearchService],
|
||||
exports: [KubernetesService, HelmService, ElasticsearchService],
|
||||
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
||||
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
||||
})
|
||||
export class KubernetesModule {}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isManagedProductType,
|
||||
} from '../common/enums';
|
||||
import { HelmService } from './helm.service';
|
||||
import { RegistryService } from './registry.service';
|
||||
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -72,6 +73,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
private configService: ConfigService,
|
||||
private clustersService: ClustersService,
|
||||
private helmService: HelmService,
|
||||
private registryService: RegistryService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -161,10 +163,45 @@ export class KubernetesService implements OnModuleInit {
|
||||
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). */
|
||||
private buildManagedHelmValues(app: Application): Record<string, any> {
|
||||
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 productType = app.productType;
|
||||
|
||||
@@ -244,7 +281,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
|
||||
private buildHelmValues(app: Application, imageUri: string): Record<string, any> {
|
||||
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 hasDb = app.databaseType !== DatabaseType.NONE;
|
||||
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
|
||||
@@ -332,14 +369,20 @@ export class KubernetesService implements OnModuleInit {
|
||||
if (isManagedProductType(app.productType)) {
|
||||
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 {
|
||||
return await this.deployViaHelm(app, imageUri);
|
||||
return await this.deployViaHelm(app, workloadImage);
|
||||
} catch (helmError: any) {
|
||||
this.logger.warn(
|
||||
`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 {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
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);
|
||||
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>> {
|
||||
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 namespace = values.app.namespace;
|
||||
const namespace = values.app.namespace as string;
|
||||
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
||||
const releaseName = app.name;
|
||||
|
||||
const result = await this.helmService.installOrUpgrade(
|
||||
@@ -463,6 +509,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
|
||||
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
await this.ensurePlatformStorageClass(kubeconfig);
|
||||
const values = this.buildManagedHelmValues(app);
|
||||
const namespace = values.app.namespace;
|
||||
const releaseName = app.name;
|
||||
@@ -482,6 +529,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
|
||||
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
|
||||
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 context: ManifestContext = {
|
||||
appName: app.name,
|
||||
@@ -546,6 +595,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
return this.deployManagedViaK8sApi(app);
|
||||
}
|
||||
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 context: ManifestContext = {
|
||||
@@ -585,6 +636,9 @@ export class KubernetesService implements OnModuleInit {
|
||||
// 1. Ensure namespace exists
|
||||
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
|
||||
if (Object.keys(context.envVars).length > 0) {
|
||||
manifests.secret = await this.applySecret(coreApi, context);
|
||||
|
||||
@@ -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 clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['admin-elasticsearch-status'],
|
||||
queryFn: () => api.get('/admin/elasticsearch/status').then((r) => r.data),
|
||||
queryKey: ['admin-elasticsearch-status', clusterId],
|
||||
queryFn: () =>
|
||||
api.get('/admin/elasticsearch/status', { params: clusterId ? { clusterId } : {} }).then((r) => r.data),
|
||||
enabled: !!clusterId,
|
||||
});
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: () => api.post('/admin/elasticsearch/deploy'),
|
||||
onSuccess: () => {
|
||||
mutationFn: (targetClusterId?: string) =>
|
||||
api.post('/admin/elasticsearch/deploy', null, {
|
||||
params: targetClusterId ? { clusterId: targetClusterId } : {},
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
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({
|
||||
mutationFn: () => api.delete('/admin/elasticsearch/undeploy'),
|
||||
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';
|
||||
@@ -158,21 +185,34 @@ function CentralLoggingPanel() {
|
||||
End users never get Kibana access — staff use port-forward.
|
||||
</p>
|
||||
</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 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending}
|
||||
onClick={() => deployMutation.mutate(clusterId)}
|
||||
disabled={deployMutation.isPending || !clusterId}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{deployMutation.isPending ? 'Deploying…' : 'Deploy stack'}
|
||||
{deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => undeployMutation.mutate()}
|
||||
disabled={undeployMutation.isPending}
|
||||
onClick={() => undeployMutation.mutate(clusterId)}
|
||||
disabled={undeployMutation.isPending || !clusterId}
|
||||
className="btn-secondary text-sm text-red-600"
|
||||
>
|
||||
Remove stack
|
||||
@@ -206,20 +246,65 @@ function CentralLoggingPanel() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-amber-700">
|
||||
Not deployed on the default cluster. New clusters install this automatically; use Deploy for existing clusters.
|
||||
</p>
|
||||
<div className="text-sm text-amber-700 space-y-1">
|
||||
<p>
|
||||
Not ready on this cluster
|
||||
{status?.deployStatus ? ` (${status.deployStatus}${status.helmReleaseStatus ? ` · helm: ${status.helmReleaseStatus}` : ''})` : ''}.
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
New clusters install automatically; if that failed (e.g. missing StorageClass), click Deploy Elastic to install or repair.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterElasticButton({ clusterId, clusterName }: { clusterId: string; clusterName: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['admin-elasticsearch-status', clusterId],
|
||||
queryFn: () => api.get('/admin/elasticsearch/status', { params: { clusterId } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: () => api.post('/admin/elasticsearch/deploy', null, { params: { clusterId } }),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-elasticsearch-status'] });
|
||||
toast.success(res.data?.message || `Elasticsearch deploy started on ${clusterName}`);
|
||||
},
|
||||
onError: (err) => toast.error(apiErrorMessage(err, `Failed to deploy Elasticsearch on ${clusterName}`)),
|
||||
});
|
||||
|
||||
if (status?.deployed) {
|
||||
return (
|
||||
<span className="text-xs text-green-700 font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Elastic
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending}
|
||||
className="btn-ghost text-sm text-indigo-700"
|
||||
title="Install or repair central Elasticsearch on this cluster"
|
||||
>
|
||||
<ScrollText className="w-3 h-3 inline" />
|
||||
{deployMutation.isPending ? 'Deploying…' : 'Deploy Elastic'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminClustersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
const [loggingClusterId, setLoggingClusterId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -308,7 +393,11 @@ export default function AdminClustersPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CentralLoggingPanel />
|
||||
<CentralLoggingPanel
|
||||
clusters={clusters}
|
||||
selectedClusterId={loggingClusterId}
|
||||
onSelectCluster={setLoggingClusterId}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<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
|
||||
</button>
|
||||
<ClusterElasticButton clusterId={cluster.id} clusterName={cluster.name} />
|
||||
<button
|
||||
onClick={() => testMutation.mutate(cluster.id)}
|
||||
disabled={testingId === cluster.id}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user