diff --git a/backend/Dockerfile b/backend/Dockerfile index 58c00f0..9ee1ce1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,12 @@ RUN npm run build # ---- Stage 2: Production ---- FROM node:20-alpine AS production -RUN apk add --no-cache dumb-init +RUN apk add --no-cache dumb-init curl bash \ + && curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz | tar xz -C /tmp \ + && mv /tmp/linux-amd64/helm /usr/local/bin/helm \ + && rm -rf /tmp/linux-amd64 \ + && curl -fsSLO "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + && chmod +x kubectl && mv kubectl /usr/local/bin/kubectl ENV NODE_ENV=production WORKDIR /app @@ -22,6 +27,7 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist COPY --from=builder /app/templates ./templates +COPY --from=builder /app/helm ./helm RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser diff --git a/backend/helm/cloudhost-app/templates/_helpers.tpl b/backend/helm/cloudhost-app/templates/_helpers.tpl index b1f818a..8c1b0a8 100644 --- a/backend/helm/cloudhost-app/templates/_helpers.tpl +++ b/backend/helm/cloudhost-app/templates/_helpers.tpl @@ -54,6 +54,10 @@ Database image — auto-computed from type + version if not explicitly set {{- .Values.database.image }} {{- else if eq .Values.database.type "postgresql" }} {{- printf "postgres:%s-alpine" .Values.database.version }} +{{- else if eq .Values.database.type "mariadb" }} + {{- printf "mariadb:%s" .Values.database.version }} +{{- else if eq .Values.database.type "mongodb" }} + {{- printf "mongo:%s" .Values.database.version }} {{- else }} {{- printf "mysql:%s" .Values.database.version }} {{- end }} @@ -63,14 +67,20 @@ Database image — auto-computed from type + version if not explicitly set Database port */}} {{- define "cloudhost-app.dbPort" -}} -{{- if eq .Values.database.type "postgresql" }}5432{{- else }}3306{{- end }} +{{- if eq .Values.database.type "postgresql" }}5432 +{{- else if eq .Values.database.type "mongodb" }}27017 +{{- else }}3306 +{{- end }} {{- end }} {{/* Database data mount path (volume mount target) */}} {{- define "cloudhost-app.dbDataPath" -}} -{{- if eq .Values.database.type "postgresql" }}/var/lib/postgresql/data{{- else }}/var/lib/mysql{{- end }} +{{- if eq .Values.database.type "postgresql" }}/var/lib/postgresql/data +{{- else if eq .Values.database.type "mongodb" }}/data/db +{{- else }}/var/lib/mysql +{{- end }} {{- end }} {{/* @@ -79,3 +89,25 @@ PostgreSQL PGDATA path — must be a subdirectory of the mount to avoid "initdb: {{- define "cloudhost-app.pgDataDir" -}} /var/lib/postgresql/data/pgdata {{- end }} + +{{/* +App storage mount path based on runtime +*/}} +{{- define "cloudhost-app.storageMountPath" -}} +{{- if eq .Values.app.runtime "wordpress" }}/var/www/html/wp-content +{{- else if or (eq .Values.app.runtime "laravel") (eq .Values.app.runtime "php") }}/var/www/html/storage +{{- else if eq .Values.app.runtime "django" }}/app/media +{{- else }}/app/data +{{- end }} +{{- end }} + +{{/* +Default log paths based on runtime +*/}} +{{- define "cloudhost-app.defaultLogPaths" -}} +{{- if eq .Values.app.runtime "wordpress" }}/var/www/html/wp-content/debug.log,/var/log/app/*.log +{{- else if eq .Values.app.runtime "laravel" }}/var/www/html/storage/logs/*.log,/var/log/app/*.log +{{- else if or (eq .Values.app.runtime "php") }}/var/www/html/storage/logs/*.log,/var/log/php/*.log,/var/log/app/*.log +{{- else }}/var/log/app/*.log +{{- end }} +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml b/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml new file mode 100644 index 0000000..391da25 --- /dev/null +++ b/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml @@ -0,0 +1,17 @@ +{{- $name := include "cloudhost-app.name" . -}} +{{- $ns := include "cloudhost-app.namespace" . -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ $name }}-storage + namespace: {{ $ns }} + labels: + {{- include "cloudhost-app.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.app.storageSize | default "2Gi" | quote }} diff --git a/backend/helm/cloudhost-app/templates/db-deployment.yaml b/backend/helm/cloudhost-app/templates/db-deployment.yaml index 1752f8f..6ce7721 100644 --- a/backend/helm/cloudhost-app/templates/db-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/db-deployment.yaml @@ -44,6 +44,37 @@ spec: secretKeyRef: name: {{ $name }}-db-secret key: password + {{- else if eq .Values.database.type "mariadb" }} + - name: MARIADB_DATABASE + value: {{ include "cloudhost-app.dbName" . }} + - name: MARIADB_USER + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + - name: MARIADB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: password + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: password + {{- else if eq .Values.database.type "mongodb" }} + - name: MONGO_INITDB_DATABASE + value: {{ include "cloudhost-app.dbName" . }} + - name: MONGO_INITDB_ROOT_USERNAME + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + - name: MONGO_INITDB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: password {{- else }} - name: MYSQL_DATABASE value: {{ include "cloudhost-app.dbName" . }} @@ -77,6 +108,12 @@ spec: {{- if eq .Values.database.type "postgresql" }} exec: command: ["pg_isready", "-U", {{ .Values.database.username | quote }}] + {{- else if eq .Values.database.type "mariadb" }} + exec: + command: ["healthcheck.sh", "--connect", "--innodb_initialized"] + {{- else if eq .Values.database.type "mongodb" }} + exec: + command: ["mongosh", "--eval", "db.adminCommand('ping')"] {{- else }} exec: command: ["mysqladmin", "ping", "-h", "127.0.0.1"] @@ -88,6 +125,12 @@ spec: {{- if eq .Values.database.type "postgresql" }} exec: command: ["pg_isready", "-U", {{ .Values.database.username | quote }}] + {{- else if eq .Values.database.type "mariadb" }} + exec: + command: ["healthcheck.sh", "--connect", "--innodb_initialized"] + {{- else if eq .Values.database.type "mongodb" }} + exec: + command: ["mongosh", "--eval", "db.adminCommand('ping')"] {{- else }} exec: command: ["mysqladmin", "ping", "-h", "127.0.0.1"] diff --git a/backend/helm/cloudhost-app/templates/deployment.yaml b/backend/helm/cloudhost-app/templates/deployment.yaml index 24718d7..ba91b84 100644 --- a/backend/helm/cloudhost-app/templates/deployment.yaml +++ b/backend/helm/cloudhost-app/templates/deployment.yaml @@ -74,6 +74,83 @@ spec: - name: DATABASE_URL value: "mysql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:3306/{{ include "cloudhost-app.dbName" . }}" {{- end }} + {{- if and .Values.database.enabled (eq .Values.database.type "mariadb") }} + - name: DB_HOST + value: {{ include "cloudhost-app.dbDeploymentName" . }} + - name: DB_PORT + value: "3306" + - name: DB_NAME + value: {{ include "cloudhost-app.dbName" . }} + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: password + - name: DATABASE_URL + value: "mysql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:3306/{{ include "cloudhost-app.dbName" . }}" + {{- end }} + {{- if and .Values.database.enabled (eq .Values.database.type "mongodb") }} + - name: DB_HOST + value: {{ include "cloudhost-app.dbDeploymentName" . }} + - name: DB_PORT + value: "27017" + - name: DB_NAME + value: {{ include "cloudhost-app.dbName" . }} + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: password + - name: MONGODB_URI + value: "mongodb://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:27017/{{ include "cloudhost-app.dbName" . }}?authSource=admin" + - name: DATABASE_URL + value: "mongodb://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:27017/{{ include "cloudhost-app.dbName" . }}?authSource=admin" + {{- end }} + {{- /* Redis connection env vars */}} + {{- if .Values.redis.enabled }} + - name: REDIS_HOST + value: {{ printf "%s-redis" $name }} + - name: REDIS_PORT + value: "6379" + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ printf "%s-redis-secret" $name }} + key: password + - name: REDIS_URL + value: "redis://:$(REDIS_PASSWORD)@{{ printf "%s-redis" $name }}:6379" + {{- end }} + {{- /* RabbitMQ connection env vars */}} + {{- if .Values.rabbitmq.enabled }} + - name: RABBITMQ_HOST + value: {{ printf "%s-rabbitmq" $name }} + - name: RABBITMQ_PORT + value: "5672" + - name: RABBITMQ_MANAGEMENT_PORT + value: "15672" + - name: RABBITMQ_USER + valueFrom: + secretKeyRef: + name: {{ printf "%s-rabbitmq-secret" $name }} + key: username + - name: RABBITMQ_PASSWORD + valueFrom: + secretKeyRef: + name: {{ printf "%s-rabbitmq-secret" $name }} + key: password + - name: AMQP_URL + value: "amqp://$(RABBITMQ_USER):$(RABBITMQ_PASSWORD)@{{ printf "%s-rabbitmq" $name }}:5672" + {{- end }} {{- /* WordPress-specific env vars (official image expects these) */}} {{- if and .Values.wordpress.enabled .Values.database.enabled }} - name: WORDPRESS_DB_HOST @@ -111,14 +188,58 @@ spec: initialDelaySeconds: 15 periodSeconds: 10 failureThreshold: 5 - {{- if .Values.wordpress.enabled }} volumeMounts: - - name: wp-content - mountPath: /var/www/html/wp-content - {{- end }} - {{- if .Values.wordpress.enabled }} + - name: app-storage + mountPath: {{ include "cloudhost-app.storageMountPath" . | trim }} + {{- if .Values.elasticsearch.enabled }} + - name: app-logs + mountPath: /var/log/app + {{- end }} + {{- if .Values.elasticsearch.enabled }} + - name: fluent-bit + image: fluent/fluent-bit:2.2 + resources: + requests: + cpu: "10m" + memory: "32Mi" + limits: + cpu: "50m" + memory: "64Mi" + volumeMounts: + - name: app-logs + mountPath: /var/log/app + readOnly: true + - name: fluent-bit-config + mountPath: /fluent-bit/etc + env: + - name: APP_NAME + value: {{ $name }} + - name: APP_NAMESPACE + value: {{ $ns }} + - name: ES_HOST + value: "elasticsearch.logging.svc.cluster.local" + - name: ES_PORT + value: "9200" + - name: ES_PASSWORD + valueFrom: + secretKeyRef: + name: elasticsearch-credentials + key: ELASTIC_PASSWORD + optional: true + {{- end }} volumes: + - name: app-storage + persistentVolumeClaim: + claimName: {{ $name }}-storage + {{- if .Values.wordpress.enabled }} - name: wp-content persistentVolumeClaim: claimName: {{ $name }}-wp-content - {{- end }} + {{- end }} + {{- if .Values.elasticsearch.enabled }} + - name: app-logs + emptyDir: {} + - name: fluent-bit-config + configMap: + name: {{ $name }}-fluent-bit-config + {{- end }} diff --git a/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml b/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml new file mode 100644 index 0000000..9edb73c --- /dev/null +++ b/backend/helm/cloudhost-app/templates/fluent-bit-configmap.yaml @@ -0,0 +1,71 @@ +{{- if .Values.elasticsearch.enabled }} +{{- $name := include "cloudhost-app.name" . -}} +{{- $ns := include "cloudhost-app.namespace" . -}} +{{- $logPaths := .Values.elasticsearch.logPaths -}} +{{- $defaultPaths := include "cloudhost-app.defaultLogPaths" . | trim -}} +{{- $paths := ternary ($logPaths | join ",") $defaultPaths (and $logPaths (gt (len $logPaths) 0)) -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $name }}-fluent-bit-config + namespace: {{ $ns }} + labels: + {{- include "cloudhost-app.labels" . | nindent 4 }} +data: + fluent-bit.conf: | + [SERVICE] + Flush 5 + Daemon Off + Log_Level info + Parsers_File /fluent-bit/etc/parsers.conf + + [INPUT] + Name tail + Path {{ $paths }} + Tag app.{{ $name }} + Refresh_Interval 5 + Mem_Buf_Limit 5MB + Skip_Long_Lines On + + [FILTER] + Name record_modifier + Match * + Record app {{ $name }} + Record namespace {{ $ns }} + Record runtime {{ .Values.app.runtime }} + + [FILTER] + Name parser + Match * + Key_Name log + Parser json + Reserve_Data On + Preserve_Key On + + [OUTPUT] + Name es + Match * + Host ${ES_HOST} + Port ${ES_PORT} + HTTP_User elastic + HTTP_Passwd ${ES_PASSWORD} + Index logs-{{ $ns }}-{{ $name }} + Logstash_Format On + Logstash_Prefix logs-{{ $ns }} + Suppress_Type_Name On + tls Off + Retry_Limit 3 + + parsers.conf: | + [PARSER] + Name json + Format json + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L + + [PARSER] + Name docker + Format json + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml b/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml new file mode 100644 index 0000000..fce15f0 --- /dev/null +++ b/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml @@ -0,0 +1,123 @@ +{{- if .Values.rabbitmq.enabled }} +{{- $name := include "cloudhost-app.name" . -}} +{{- $ns := include "cloudhost-app.namespace" . -}} +{{- $rabbitName := printf "%s-rabbitmq" $name -}} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $rabbitName }}-secret + namespace: {{ $ns }} + labels: + app: {{ $rabbitName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +type: Opaque +data: + username: {{ "appuser" | b64enc | quote }} + password: {{ randAlphaNum 16 | b64enc | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ $rabbitName }}-data + namespace: {{ $ns }} + labels: + app: {{ $rabbitName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.rabbitmq.storageSize | default "2Gi" | quote }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $rabbitName }} + namespace: {{ $ns }} + labels: + app: {{ $rabbitName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ $rabbitName }} + template: + metadata: + labels: + app: {{ $rabbitName }} + spec: + containers: + - name: rabbitmq + image: {{ printf "rabbitmq:%s-management-alpine" (.Values.rabbitmq.version | default "3.13") }} + ports: + - containerPort: 5672 + name: amqp + - containerPort: 15672 + name: management + env: + - name: RABBITMQ_DEFAULT_USER + valueFrom: + secretKeyRef: + name: {{ $rabbitName }}-secret + key: username + - name: RABBITMQ_DEFAULT_PASS + valueFrom: + secretKeyRef: + name: {{ $rabbitName }}-secret + key: password + volumeMounts: + - name: rabbitmq-data + mountPath: /var/lib/rabbitmq + resources: + requests: + cpu: {{ .Values.rabbitmq.resources.cpuRequest | default "100m" | quote }} + memory: {{ .Values.rabbitmq.resources.memoryRequest | default "256Mi" | quote }} + limits: + cpu: {{ .Values.rabbitmq.resources.cpuLimit | default "500m" | quote }} + memory: {{ .Values.rabbitmq.resources.memoryLimit | default "512Mi" | quote }} + readinessProbe: + exec: + command: ["rabbitmq-diagnostics", "-q", "ping"] + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: ["rabbitmq-diagnostics", "-q", "status"] + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + volumes: + - name: rabbitmq-data + persistentVolumeClaim: + claimName: {{ $rabbitName }}-data +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $rabbitName }} + namespace: {{ $ns }} + labels: + app: {{ $rabbitName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ $rabbitName }} + ports: + - port: 5672 + targetPort: 5672 + protocol: TCP + name: amqp + - port: 15672 + targetPort: 15672 + protocol: TCP + name: management +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/redis-deployment.yaml b/backend/helm/cloudhost-app/templates/redis-deployment.yaml new file mode 100644 index 0000000..6e666f1 --- /dev/null +++ b/backend/helm/cloudhost-app/templates/redis-deployment.yaml @@ -0,0 +1,108 @@ +{{- if .Values.redis.enabled }} +{{- $name := include "cloudhost-app.name" . -}} +{{- $ns := include "cloudhost-app.namespace" . -}} +{{- $redisName := printf "%s-redis" $name -}} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $redisName }}-secret + namespace: {{ $ns }} + labels: + app: {{ $redisName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +type: Opaque +data: + password: {{ randAlphaNum 16 | b64enc | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ $redisName }}-data + namespace: {{ $ns }} + labels: + app: {{ $redisName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.redis.storageSize | default "1Gi" | quote }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $redisName }} + namespace: {{ $ns }} + labels: + app: {{ $redisName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ $redisName }} + template: + metadata: + labels: + app: {{ $redisName }} + spec: + containers: + - name: redis + image: {{ printf "redis:%s-alpine" (.Values.redis.version | default "7.2") }} + args: ["--requirepass", "$(REDIS_PASSWORD)"] + ports: + - containerPort: 6379 + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $redisName }}-secret + key: password + volumeMounts: + - name: redis-data + mountPath: /data + resources: + requests: + cpu: {{ .Values.redis.resources.cpuRequest | default "50m" | quote }} + memory: {{ .Values.redis.resources.memoryRequest | default "64Mi" | quote }} + limits: + cpu: {{ .Values.redis.resources.cpuLimit | default "200m" | quote }} + memory: {{ .Values.redis.resources.memoryLimit | default "256Mi" | quote }} + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 20 + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: {{ $redisName }}-data +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $redisName }} + namespace: {{ $ns }} + labels: + app: {{ $redisName }} + {{- include "cloudhost-app.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ $redisName }} + ports: + - port: 6379 + targetPort: 6379 + protocol: TCP +{{- end }} diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index d01c62b..a339d5a 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -7,10 +7,11 @@ app: name: my-app namespace: user-default - runtime: nodejs # nodejs | laravel | wordpress + runtime: nodejs # nodejs | laravel | wordpress | go | php | python | django | dotnet image: "" # e.g. registry.example.com/my-app:v1 port: 3000 replicas: 1 + storageSize: "2Gi" # PVC size for app files (uploads, media, data) # ── Resources ──────────────────────────────────────────── resources: @@ -51,6 +52,33 @@ wordpress: enabled: false wpContentStorageSize: "2Gi" +# ── Redis ────────────────────────────────────────────── +redis: + enabled: false + version: "7.2" + storageSize: "1Gi" + resources: + cpuRequest: "50m" + cpuLimit: "200m" + memoryRequest: "64Mi" + memoryLimit: "256Mi" + +# ── RabbitMQ ────────────────────────────────────────── +rabbitmq: + enabled: false + version: "3.13" + storageSize: "2Gi" + resources: + cpuRequest: "100m" + cpuLimit: "500m" + memoryRequest: "256Mi" + memoryLimit: "512Mi" + +# ── Elasticsearch (Fluent Bit sidecar for logging) ──── +elasticsearch: + enabled: false + logPaths: [] + # ── Change metadata ───────────────────────────────────── changeCause: "" diff --git a/backend/src/kubernetes/helm.service.ts b/backend/src/kubernetes/helm.service.ts index 751165e..546b225 100644 --- a/backend/src/kubernetes/helm.service.ts +++ b/backend/src/kubernetes/helm.service.ts @@ -31,8 +31,14 @@ export class HelmService { private readonly chartPath: string; constructor() { - // Resolve the chart path relative to the backend project root - this.chartPath = path.resolve(__dirname, '..', '..', 'helm', 'cloudhost-app'); + // In production (dist/kubernetes/), __dirname resolves to dist/kubernetes + // so we go up two levels to project root, then into helm/ + // In Docker, the helm/ dir is copied alongside dist/ at /app/helm/ + const candidates = [ + path.resolve(__dirname, '..', '..', 'helm', 'cloudhost-app'), + path.resolve(process.cwd(), 'helm', 'cloudhost-app'), + ]; + this.chartPath = candidates.find((p) => fs.existsSync(p)) || candidates[0]; } /** diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 79d86f5..eab131b 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -34,8 +34,11 @@ interface ManifestContext { dbStorageSize: string; appStorageSize: string; enableRedis: boolean; + redisVersion: string; enableRabbitmq: boolean; + rabbitmqVersion: string; enableElasticsearch: boolean; + elasticsearchVersion: string; logPaths: string[]; } @@ -225,8 +228,11 @@ export class KubernetesService implements OnModuleInit { dbStorageSize: app.dbStorageSize || '1Gi', appStorageSize: app.appStorageSize || '2Gi', enableRedis: app.enableRedis || false, + redisVersion: app.redisVersion || '7.2', enableRabbitmq: app.enableRabbitmq || false, + rabbitmqVersion: app.rabbitmqVersion || '3.13', enableElasticsearch: app.enableElasticsearch || false, + elasticsearchVersion: app.elasticsearchVersion || '8.12', logPaths: app.logPaths || [], }; @@ -412,6 +418,7 @@ export class KubernetesService implements OnModuleInit { template: { metadata: { labels: { app: ctx.appName, runtime: ctx.runtime } }, spec: { + imagePullSecrets: [{ name: 'registry-pull-secret' }], containers: this.buildContainersSpec(ctx, envFrom, extraEnv), volumes: this.buildVolumesSpec(ctx), }, @@ -511,12 +518,6 @@ export class KubernetesService implements OnModuleInit { // Add Fluent Bit sidecar for log collection if Elasticsearch is enabled if (ctx.enableElasticsearch) { - const logPaths = ctx.logPaths && ctx.logPaths.length > 0 - ? ctx.logPaths - : ['/var/log/app/*.log']; - - const fluentbitConfig = this.buildFluentBitConfig(ctx.appName, ctx.namespace, logPaths); - containers.push({ name: 'fluent-bit', image: 'fluent/fluent-bit:2.2', @@ -531,9 +532,9 @@ export class KubernetesService implements OnModuleInit { env: [ { name: 'APP_NAME', value: ctx.appName }, { name: 'APP_NAMESPACE', value: ctx.namespace }, - // Elasticsearch host - assumes cluster-level ES at elasticsearch.logging namespace { name: 'ES_HOST', value: 'elasticsearch.logging.svc.cluster.local' }, { name: 'ES_PORT', value: '9200' }, + { name: 'ES_PASSWORD', valueFrom: { secretKeyRef: { name: 'elasticsearch-credentials', key: 'ELASTIC_PASSWORD', optional: true } } }, ], }); } @@ -569,40 +570,84 @@ export class KubernetesService implements OnModuleInit { return volumes; } + /** + * Get default log paths based on runtime type + */ + private getDefaultLogPaths(runtime: string): string[] { + switch (runtime) { + case AppRuntime.WORDPRESS: + return ['/var/www/html/wp-content/debug.log', '/var/log/app/*.log']; + case AppRuntime.LARAVEL: + return ['/var/www/html/storage/logs/*.log', '/var/log/app/*.log']; + case AppRuntime.PHP: + return ['/var/www/html/storage/logs/*.log', '/var/log/php/*.log', '/var/log/app/*.log']; + case AppRuntime.DJANGO: + return ['/app/logs/*.log', '/var/log/app/*.log']; + case AppRuntime.PYTHON: + return ['/app/logs/*.log', '/var/log/app/*.log']; + case AppRuntime.NODEJS: + return ['/app/logs/*.log', '/var/log/app/*.log']; + case AppRuntime.GO: + return ['/app/logs/*.log', '/var/log/app/*.log']; + case AppRuntime.DOTNET: + return ['/app/logs/*.log', '/var/log/app/*.log']; + default: + return ['/var/log/app/*.log']; + } + } + /** * Build Fluent Bit configuration for log collection */ - private buildFluentBitConfig(appName: string, namespace: string, logPaths: string[]): string { + private buildFluentBitConfig(appName: string, namespace: string, runtime: string, customLogPaths?: string[]): string { + const logPaths = customLogPaths && customLogPaths.length > 0 + ? customLogPaths + : this.getDefaultLogPaths(runtime); const pathsStr = logPaths.join(','); + return ` [SERVICE] Flush 5 Daemon Off Log_Level info + Parsers_File /fluent-bit/etc/parsers.conf [INPUT] Name tail Path ${pathsStr} Tag app.${appName} - Parser json Refresh_Interval 5 + Mem_Buf_Limit 5MB + Skip_Long_Lines On [FILTER] Name record_modifier Match * Record app ${appName} Record namespace ${namespace} + Record runtime ${runtime} + +[FILTER] + Name parser + Match * + Key_Name log + Parser json + Reserve_Data On + Preserve_Key On [OUTPUT] Name es Match * Host \${ES_HOST} Port \${ES_PORT} + HTTP_User elastic + HTTP_Passwd \${ES_PASSWORD} Index logs-${namespace}-${appName} - Type _doc Logstash_Format On Logstash_Prefix logs-${namespace} Suppress_Type_Name On + tls Off + Retry_Limit 3 `; } @@ -615,9 +660,9 @@ export class KubernetesService implements OnModuleInit { ): Promise { if (!ctx.enableElasticsearch) return; - const logPaths = ctx.logPaths && ctx.logPaths.length > 0 + const customLogPaths = ctx.logPaths && ctx.logPaths.length > 0 ? ctx.logPaths - : ['/var/log/app/*.log']; + : undefined; const configMap = { apiVersion: 'v1', @@ -628,7 +673,7 @@ export class KubernetesService implements OnModuleInit { labels: { app: ctx.appName }, }, data: { - 'fluent-bit.conf': this.buildFluentBitConfig(ctx.appName, ctx.namespace, logPaths), + 'fluent-bit.conf': this.buildFluentBitConfig(ctx.appName, ctx.namespace, ctx.runtime, customLogPaths), 'parsers.conf': ` [PARSER] Name json @@ -920,20 +965,20 @@ export class KubernetesService implements OnModuleInit { // Create PVC for Redis persistence await this.createPVC(coreApi, ctx.namespace, `${redisName}-data`, '1Gi'); - // Create Redis password secret - const redisPassword = this.generatePassword(16); - const redisSecret = { - apiVersion: 'v1', - kind: 'Secret', - metadata: { name: `${redisName}-secret`, namespace: ctx.namespace }, - data: { - password: Buffer.from(redisPassword).toString('base64'), - }, - }; - + // Only create Redis password secret if it doesn't already exist try { - await coreApi.replaceNamespacedSecret(`${redisName}-secret`, ctx.namespace, redisSecret); + await coreApi.readNamespacedSecret(`${redisName}-secret`, ctx.namespace); + this.logger.log(`Redis secret ${redisName}-secret already exists, skipping`); } catch { + const redisPassword = this.generatePassword(16); + const redisSecret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: `${redisName}-secret`, namespace: ctx.namespace }, + data: { + password: Buffer.from(redisPassword).toString('base64'), + }, + }; await coreApi.createNamespacedSecret(ctx.namespace, redisSecret); } @@ -951,7 +996,7 @@ export class KubernetesService implements OnModuleInit { containers: [ { name: 'redis', - image: 'redis:7.2-alpine', + image: `redis:${ctx.redisVersion}-alpine`, args: ['--requirepass', '$(REDIS_PASSWORD)'], ports: [{ containerPort: 6379 }], env: [ @@ -1032,22 +1077,22 @@ export class KubernetesService implements OnModuleInit { // Create PVC for RabbitMQ persistence await this.createPVC(coreApi, ctx.namespace, `${rabbitName}-data`, '2Gi'); - // Create RabbitMQ credentials secret - const rabbitUser = 'appuser'; - const rabbitPassword = this.generatePassword(16); - const rabbitSecret = { - apiVersion: 'v1', - kind: 'Secret', - metadata: { name: `${rabbitName}-secret`, namespace: ctx.namespace }, - data: { - username: Buffer.from(rabbitUser).toString('base64'), - password: Buffer.from(rabbitPassword).toString('base64'), - }, - }; - + // Only create RabbitMQ credentials secret if it doesn't already exist try { - await coreApi.replaceNamespacedSecret(`${rabbitName}-secret`, ctx.namespace, rabbitSecret); + await coreApi.readNamespacedSecret(`${rabbitName}-secret`, ctx.namespace); + this.logger.log(`RabbitMQ secret ${rabbitName}-secret already exists, skipping`); } catch { + const rabbitUser = 'appuser'; + const rabbitPassword = this.generatePassword(16); + const rabbitSecret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: `${rabbitName}-secret`, namespace: ctx.namespace }, + data: { + username: Buffer.from(rabbitUser).toString('base64'), + password: Buffer.from(rabbitPassword).toString('base64'), + }, + }; await coreApi.createNamespacedSecret(ctx.namespace, rabbitSecret); } @@ -1065,7 +1110,7 @@ export class KubernetesService implements OnModuleInit { containers: [ { name: 'rabbitmq', - image: 'rabbitmq:3.13-management-alpine', + image: `rabbitmq:${ctx.rabbitmqVersion}-management-alpine`, ports: [ { containerPort: 5672, name: 'amqp' }, { containerPort: 15672, name: 'management' }, @@ -1601,8 +1646,18 @@ export class KubernetesService implements OnModuleInit { () => coreApi.deleteNamespacedPersistentVolumeClaim(`${app.name}-wp-content`, namespace), // App env secret () => coreApi.deleteNamespacedSecret(`${app.name}-env`, namespace), - // Registry pull secret (shared, but labeled per-app — safe to delete) - () => coreApi.deleteNamespacedSecret('registry-pull-secret', namespace), + // Fluent Bit config (if elasticsearch was enabled) + () => coreApi.deleteNamespacedConfigMap(`${app.name}-fluent-bit-config`, namespace), + // Redis resources + () => appsApi.deleteNamespacedDeployment(`${app.name}-redis`, namespace), + () => coreApi.deleteNamespacedService(`${app.name}-redis`, namespace), + () => coreApi.deleteNamespacedPersistentVolumeClaim(`${app.name}-redis-data`, namespace), + () => coreApi.deleteNamespacedSecret(`${app.name}-redis-secret`, namespace), + // RabbitMQ resources + () => appsApi.deleteNamespacedDeployment(`${app.name}-rabbitmq`, namespace), + () => coreApi.deleteNamespacedService(`${app.name}-rabbitmq`, namespace), + () => coreApi.deleteNamespacedPersistentVolumeClaim(`${app.name}-rabbitmq-data`, namespace), + () => coreApi.deleteNamespacedSecret(`${app.name}-rabbitmq-secret`, namespace), // TLS secret created by cert-manager () => coreApi.deleteNamespacedSecret(`${app.name}-tls`, namespace), ];