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
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
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 {
|
try {
|
||||||
await coreApi.readNamespacedSecret(registrySecretName, buildNs);
|
await coreApi.readNamespacedSecret(registrySecretName, buildNs);
|
||||||
this.logger.log(`Secret "${registrySecretName}" already exists`);
|
await coreApi.replaceNamespacedSecret(registrySecretName, buildNs, kanikoRegistrySecret);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||||
// Parse registry host (without port path) for the docker config
|
await coreApi.createNamespacedSecret(buildNs, kanikoRegistrySecret);
|
||||||
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'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 2–5 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,
|
||||||
|
|||||||
@@ -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,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> {
|
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'),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
</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>
|
</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}
|
||||||
|
|||||||
@@ -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