fix(platform): apply production hardening from audit plan

Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-07-02 19:35:07 +03:30
parent 34c110be6a
commit 22359be40e
55 changed files with 4883 additions and 381 deletions
+66 -1
View File
@@ -71,6 +71,63 @@ jobs:
ENDSCRIPT ENDSCRIPT
chmod +x wait_for_job.sh chmod +x wait_for_job.sh
- name: Run backend tests (Job)
shell: sh
run: |
JOB_NAME="test-be-$(echo $IMAGE_TAG | tr '.:' '-' | cut -c1-50)"
cat <<ENDJOB | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: ${JOB_NAME}
namespace: ${BUILD_NS}
spec:
ttlSecondsAfterFinished: 3600
backoffLimit: 0
template:
spec:
restartPolicy: Never
imagePullSecrets:
- name: registry-pull-secret
containers:
- name: test
image: ${PULL_REGISTRY}/${PROJECT}/node:24-alpine
envFrom:
- secretRef:
name: registry-egress-proxy
command:
- sh
- -c
- |
apk add --no-cache git &&
git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace &&
cd /workspace/backend &&
npm ci --legacy-peer-deps &&
npm run test -- --ci --runInBand
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: "2", memory: 3Gi }
ENDJOB
echo "Waiting for backend test job: ${JOB_NAME}"
# Reuse the waiter but read logs from the "test" container on failure
DEADLINE=$(( $(date +%s) + 1800 ))
while :; do
CONDS="$(kubectl -n ${BUILD_NS} get job/${JOB_NAME} -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}' 2>/dev/null)"
case "$CONDS" in
*Complete=True*) echo "Tests passed"; break ;;
*Failed=True*)
echo "Tests FAILED — logs:"
kubectl -n ${BUILD_NS} logs job/${JOB_NAME} -c test --tail=200 || true
exit 1 ;;
esac
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
echo "Timed out waiting for tests — logs:"
kubectl -n ${BUILD_NS} logs job/${JOB_NAME} -c test --tail=200 || true
exit 1
fi
sleep 15
done
- name: Build backend image (Kaniko Job) - name: Build backend image (Kaniko Job)
shell: sh shell: sh
run: | run: |
@@ -219,5 +276,13 @@ jobs:
git add "${VALUES}" git add "${VALUES}"
if ! git diff --cached --quiet; then if ! git diff --cached --quiet; then
git commit -m "ci: deploy platform ${IMAGE_TAG}" git commit -m "ci: deploy platform ${IMAGE_TAG}"
git push origin HEAD:main # Retry with rebase — another pipeline may have pushed meanwhile.
for attempt in 1 2 3; do
if git push origin HEAD:main; then
break
fi
echo "Push rejected (attempt ${attempt}) — rebasing on latest main"
git pull --rebase origin main
[ "$attempt" = "3" ] && { echo "Giving up after 3 attempts"; exit 1; }
done
fi fi
+8
View File
@@ -25,6 +25,14 @@ jobs:
- run: npm run lint:check - run: npm run lint:check
- run: npm test -- --passWithNoTests - run: npm test -- --passWithNoTests
- run: npm run test:e2e - run: npm run test:e2e
- name: Verify Helm migration ConfigMap is in sync
run: |
npm run sync:migrations
if ! git diff --quiet -- helm/cloudhost-platform/migrations; then
echo "::error::helm/cloudhost-platform/migrations is out of sync with backend/migrations. Run 'npm run sync:migrations' and commit."
git --no-pager diff --stat -- helm/cloudhost-platform/migrations
exit 1
fi
frontend: frontend:
name: Frontend name: Frontend
Binary file not shown.
@@ -46,6 +46,16 @@ Database deployment name
{{- printf "%s-db" .Values.app.name }} {{- printf "%s-db" .Values.app.name }}
{{- end }} {{- end }}
{{/*
Optional mirror registry prefix for Docker Hub images.
Usage: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" "redis:7.2-alpine") }}
*/}}
{{- define "cloudhost-app.baseImage" -}}
{{- $reg := "" -}}
{{- with .root.Values.images -}}{{- $reg = .baseRegistry | default "" -}}{{- end -}}
{{- if $reg -}}{{ printf "%s/%s" $reg .image }}{{- else -}}{{ .image }}{{- end -}}
{{- end }}
{{/* {{/*
Database image auto-computed from type + version if not explicitly set Database image auto-computed from type + version if not explicitly set
*/}} */}}
@@ -53,13 +63,13 @@ Database image — auto-computed from type + version if not explicitly set
{{- if .Values.database.image }} {{- if .Values.database.image }}
{{- .Values.database.image }} {{- .Values.database.image }}
{{- else if eq .Values.database.type "postgresql" }} {{- else if eq .Values.database.type "postgresql" }}
{{- printf "postgres:%s-alpine" .Values.database.version }} {{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "postgres:%s-alpine" .Values.database.version)) }}
{{- else if eq .Values.database.type "mariadb" }} {{- else if eq .Values.database.type "mariadb" }}
{{- printf "mariadb:%s" .Values.database.version }} {{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mariadb:%s" .Values.database.version)) }}
{{- else if eq .Values.database.type "mongodb" }} {{- else if eq .Values.database.type "mongodb" }}
{{- printf "mongo:%s" .Values.database.version }} {{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mongo:%s" .Values.database.version)) }}
{{- else }} {{- else }}
{{- printf "mysql:%s" .Values.database.version }} {{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mysql:%s" .Values.database.version)) }}
{{- end }} {{- end }}
{{- end }} {{- end }}
@@ -5,7 +5,7 @@
{{- define "cloudhost-app.logShipperContainers" -}} {{- define "cloudhost-app.logShipperContainers" -}}
{{- if .root.Values.elasticsearch.enabled }} {{- if .root.Values.elasticsearch.enabled }}
- name: log-shipper - name: log-shipper
image: fluent/fluent-bit:2.2 image: {{ include "cloudhost-app.baseImage" (dict "root" .root "image" "fluent/fluent-bit:2.2") }}
resources: resources:
requests: requests:
cpu: "10m" cpu: "10m"
@@ -16,6 +16,10 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }} {{- include "cloudhost-app.labels" . | nindent 4 }}
spec: spec:
replicas: 1 replicas: 1
# RWO volume + single replica: recreate the old pod before starting the new
# one — a rolling update would deadlock on the attached PVC.
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: {{ $dbName }} app: {{ $dbName }}
@@ -114,7 +118,7 @@ spec:
command: ["healthcheck.sh", "--connect", "--innodb_initialized"] command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
{{- else if eq .Values.database.type "mongodb" }} {{- else if eq .Values.database.type "mongodb" }}
exec: exec:
command: ["mongosh", "--eval", "db.adminCommand('ping')"] command: ["sh", "-c", "mongosh --quiet -u \"$MONGO_INITDB_ROOT_USERNAME\" -p \"$MONGO_INITDB_ROOT_PASSWORD\" --eval \"db.adminCommand('ping')\""]
{{- else }} {{- else }}
exec: exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"] command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
@@ -131,7 +135,7 @@ spec:
command: ["healthcheck.sh", "--connect", "--innodb_initialized"] command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
{{- else if eq .Values.database.type "mongodb" }} {{- else if eq .Values.database.type "mongodb" }}
exec: exec:
command: ["mongosh", "--eval", "db.adminCommand('ping')"] command: ["sh", "-c", "mongosh --quiet -u \"$MONGO_INITDB_ROOT_USERNAME\" -p \"$MONGO_INITDB_ROOT_PASSWORD\" --eval \"db.adminCommand('ping')\""]
{{- else }} {{- else }}
exec: exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"] command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
@@ -217,7 +217,7 @@ spec:
{{- end }} {{- end }}
{{- if .Values.elasticsearch.enabled }} {{- if .Values.elasticsearch.enabled }}
- name: fluent-bit - name: fluent-bit
image: fluent/fluent-bit:2.2 image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" "fluent/fluent-bit:2.2") }}
resources: resources:
requests: requests:
cpu: "10m" cpu: "10m"
@@ -1,4 +1,7 @@
{{- if .Values.elasticsearch.enabled }} {{- if .Values.elasticsearch.enabled }}
{{- if not .Values.elasticsearch.fluentbitPassword }}
{{- fail "elasticsearch.fluentbitPassword is required when elasticsearch.enabled=true — no hardcoded default is shipped" }}
{{- end }}
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -8,7 +11,7 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }} {{- include "cloudhost-app.labels" . | nindent 4 }}
type: Opaque type: Opaque
stringData: stringData:
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | default "CloudHost2024!Secure" | quote }} ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | quote }}
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | default "FluentBit2024!Writer" | quote }} FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | quote }}
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | default "Kibana2024!System" | quote }} KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | quote }}
{{- end }} {{- end }}
@@ -2,11 +2,22 @@
{{- $name := include "cloudhost-app.name" . -}} {{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}} {{- $ns := include "cloudhost-app.namespace" . -}}
{{- $rabbitName := printf "%s-rabbitmq" $name -}} {{- $rabbitName := printf "%s-rabbitmq" $name -}}
{{- /* Preserve the existing password across upgrades — RabbitMQ only applies
RABBITMQ_DEFAULT_PASS on first boot, so a regenerated secret would
diverge from the credentials stored in the persisted volume. */ -}}
{{- $rabbitSecretName := printf "%s-secret" $rabbitName -}}
{{- $existingRabbit := lookup "v1" "Secret" $ns $rabbitSecretName -}}
{{- $rabbitPass := "" -}}
{{- if and $existingRabbit $existingRabbit.data (index $existingRabbit.data "password") -}}
{{- $rabbitPass = index $existingRabbit.data "password" | b64dec -}}
{{- else -}}
{{- $rabbitPass = randAlphaNum 16 -}}
{{- end -}}
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: {{ $rabbitName }}-secret name: {{ $rabbitSecretName }}
namespace: {{ $ns }} namespace: {{ $ns }}
labels: labels:
app: {{ $rabbitName }} app: {{ $rabbitName }}
@@ -16,7 +27,7 @@ metadata:
type: Opaque type: Opaque
data: data:
username: {{ "appuser" | b64enc | quote }} username: {{ "appuser" | b64enc | quote }}
password: {{ randAlphaNum 16 | b64enc | quote }} password: {{ $rabbitPass | b64enc | quote }}
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
@@ -48,6 +59,9 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }} {{- include "cloudhost-app.labels" . | nindent 4 }}
spec: spec:
replicas: 1 replicas: 1
# RWO volume + single replica: recreate instead of rolling update.
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: {{ $rabbitName }} app: {{ $rabbitName }}
@@ -58,7 +72,7 @@ spec:
spec: spec:
containers: containers:
- name: rabbitmq - name: rabbitmq
image: {{ printf "rabbitmq:%s-management-alpine" (.Values.rabbitmq.version | default "3.13") }} image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "rabbitmq:%s-management-alpine" (.Values.rabbitmq.version | default "3.13"))) }}
ports: ports:
- containerPort: 5672 - containerPort: 5672
name: amqp name: amqp
@@ -68,12 +82,12 @@ spec:
- name: RABBITMQ_DEFAULT_USER - name: RABBITMQ_DEFAULT_USER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ $rabbitName }}-secret name: {{ $rabbitSecretName }}
key: username key: username
- name: RABBITMQ_DEFAULT_PASS - name: RABBITMQ_DEFAULT_PASS
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ $rabbitName }}-secret name: {{ $rabbitSecretName }}
key: password key: password
volumeMounts: volumeMounts:
- name: rabbitmq-data - name: rabbitmq-data
@@ -2,11 +2,21 @@
{{- $name := include "cloudhost-app.name" . -}} {{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}} {{- $ns := include "cloudhost-app.namespace" . -}}
{{- $redisName := printf "%s-redis" $name -}} {{- $redisName := printf "%s-redis" $name -}}
{{- /* Preserve the existing password across upgrades — regenerating it every
upgrade would break app↔Redis auth against the persisted volume. */ -}}
{{- $redisSecretName := printf "%s-secret" $redisName -}}
{{- $existingRedis := lookup "v1" "Secret" $ns $redisSecretName -}}
{{- $redisPass := "" -}}
{{- if and $existingRedis $existingRedis.data (index $existingRedis.data "password") -}}
{{- $redisPass = index $existingRedis.data "password" | b64dec -}}
{{- else -}}
{{- $redisPass = randAlphaNum 16 -}}
{{- end -}}
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: {{ $redisName }}-secret name: {{ $redisSecretName }}
namespace: {{ $ns }} namespace: {{ $ns }}
labels: labels:
app: {{ $redisName }} app: {{ $redisName }}
@@ -15,7 +25,7 @@ metadata:
"helm.sh/resource-policy": keep "helm.sh/resource-policy": keep
type: Opaque type: Opaque
data: data:
password: {{ randAlphaNum 16 | b64enc | quote }} password: {{ $redisPass | b64enc | quote }}
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
@@ -47,6 +57,10 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }} {{- include "cloudhost-app.labels" . | nindent 4 }}
spec: spec:
replicas: 1 replicas: 1
# RWO volume + single replica: recreate the old pod before starting the new
# one, otherwise a rolling update deadlocks on the attached PVC.
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: {{ $redisName }} app: {{ $redisName }}
@@ -57,7 +71,7 @@ spec:
spec: spec:
containers: containers:
- name: redis - name: redis
image: {{ printf "redis:%s-alpine" (.Values.redis.version | default "7.2") }} image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "redis:%s-alpine" (.Values.redis.version | default "7.2"))) }}
args: ["--requirepass", "$(REDIS_PASSWORD)"] args: ["--requirepass", "$(REDIS_PASSWORD)"]
ports: ports:
- containerPort: 6379 - containerPort: 6379
@@ -65,7 +79,14 @@ spec:
- name: REDIS_PASSWORD - name: REDIS_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ $redisName }}-secret name: {{ $redisSecretName }}
key: password
# redis-cli in the probes auto-authenticates from REDISCLI_AUTH,
# so `redis-cli ping` works even with --requirepass set.
- name: REDISCLI_AUTH
valueFrom:
secretKeyRef:
name: {{ $redisSecretName }}
key: password key: password
volumeMounts: volumeMounts:
- name: redis-data - name: redis-data
+6
View File
@@ -101,3 +101,9 @@ changeCause: ""
# ── Registry (for imagePullSecret) ────────────────────── # ── Registry (for imagePullSecret) ──────────────────────
registry: registry:
url: "localhost:30500" url: "localhost:30500"
# ── Base images ──────────────────────────────────────────
images:
# Optional mirror registry prefix for Docker Hub images (postgres, mysql,
# redis, rabbitmq, fluent-bit, …), e.g. "mirror.example.com".
baseRegistry: ""
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,20 @@
-- Temporary external access grants (Redis, RabbitMQ, database) -- Temporary external access grants (Redis, RabbitMQ, database)
DO $$ BEGIN
CREATE TYPE service_access_target AS ENUM ( CREATE TYPE service_access_target AS ENUM (
'database', 'database',
'redis', 'redis',
'rabbitmq_amqp', 'rabbitmq_amqp',
'rabbitmq_management' 'rabbitmq_management'
); );
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE service_access_grant_status AS ENUM ( CREATE TYPE service_access_grant_status AS ENUM (
'active', 'active',
'expired', 'expired',
'revoked' 'revoked'
); );
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS service_access_grants ( CREATE TABLE IF NOT EXISTS service_access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -3,7 +3,7 @@ ALTER TABLE applications
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
CREATE INDEX IF NOT EXISTS idx_applications_user_product_type CREATE INDEX IF NOT EXISTS idx_applications_user_product_type
ON applications (user_id, product_type); ON applications ("userId", product_type);
ALTER TABLE resource_credits ALTER TABLE resource_credits
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
@@ -9,8 +9,14 @@ metadata:
{{- include "cloudhost-platform.labels" . | nindent 4 }} {{- include "cloudhost-platform.labels" . | nindent 4 }}
spec: spec:
replicas: {{ .Values.backend.replicas }} replicas: {{ .Values.backend.replicas }}
# Zero-downtime rollouts: DB migrations run in a pre-upgrade hook Job, so the
# new pod only starts against a ready schema. The uploads PVC is RWO but
# local-path volumes pin pods to the same node, so surge pods can attach.
strategy: strategy:
type: Recreate type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector: selector:
matchLabels: matchLabels:
app: {{ include "cloudhost-platform.backend.fullname" . }} app: {{ include "cloudhost-platform.backend.fullname" . }}
@@ -72,6 +78,11 @@ spec:
value: {{ include "cloudhost-platform.redis.fullname" . }} value: {{ include "cloudhost-platform.redis.fullname" . }}
- name: REDIS_PORT - name: REDIS_PORT
value: "6379" value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: redis-password
- name: JWT_SECRET - name: JWT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -7,7 +7,9 @@ metadata:
labels: labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }} {{- include "cloudhost-platform.labels" . | nindent 4 }}
annotations: annotations:
helm.sh/hook: post-install,post-upgrade # Run BEFORE the backend rolls out so schema-dependent code never starts
# against an unmigrated database.
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "5" helm.sh/hook-weight: "5"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec: spec:
@@ -47,9 +49,20 @@ spec:
- -c - -c
- | - |
set -e set -e
# Track applied migrations so each file runs exactly once — the
# loop is idempotent across every helm upgrade.
psql -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW());"
for f in $(ls /migrations/*.sql | sort); do for f in $(ls /migrations/*.sql | sort); do
echo ">>> Applying $f" name=$(basename "$f")
psql -v ON_ERROR_STOP=1 -f "$f" applied=$(psql -tA -c "SELECT 1 FROM schema_migrations WHERE filename = '$name';")
if [ "$applied" = "1" ]; then
echo ">>> Skipping $name (already applied)"
continue
fi
echo ">>> Applying $name"
psql -v ON_ERROR_STOP=1 --single-transaction \
-f "$f" \
-c "INSERT INTO schema_migrations (filename) VALUES ('$name');"
done done
echo ">>> All migrations applied" echo ">>> All migrations applied"
volumeMounts: volumeMounts:
@@ -41,6 +41,8 @@ spec:
FILE="/backup/cloudhost-${STAMP}.sql.gz" FILE="/backup/cloudhost-${STAMP}.sql.gz"
pg_dump | gzip > "$FILE" pg_dump | gzip > "$FILE"
echo "Backup written to $FILE" echo "Backup written to $FILE"
# Retention: keep the last {{ .Values.backups.postgres.retentionDays | default 7 }} days
find /backup -name 'cloudhost-*.sql.gz' -mtime +{{ .Values.backups.postgres.retentionDays | default 7 }} -delete
volumeMounts: volumeMounts:
- name: backup - name: backup
mountPath: /backup mountPath: /backup
@@ -19,6 +19,10 @@ spec:
labels: labels:
app: {{ include "cloudhost-platform.postgres.fullname" . }} app: {{ include "cloudhost-platform.postgres.fullname" . }}
spec: spec:
{{- with .Values.postgres.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers: containers:
- name: postgres - name: postgres
image: {{ .Values.images.postgres | quote }} image: {{ .Values.images.postgres | quote }}
@@ -19,9 +19,26 @@ spec:
labels: labels:
app: {{ include "cloudhost-platform.redis.fullname" . }} app: {{ include "cloudhost-platform.redis.fullname" . }}
spec: spec:
{{- with .Values.redis.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers: containers:
- name: redis - name: redis
image: {{ .Values.images.redis | quote }} image: {{ .Values.images.redis | quote }}
args: ["--requirepass", "$(REDIS_PASSWORD)"]
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: redis-password
# redis-cli reads REDISCLI_AUTH so authenticated probes need no -a flag
- name: REDISCLI_AUTH
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: redis-password
ports: ports:
- containerPort: 6379 - containerPort: 6379
volumeMounts: volumeMounts:
@@ -22,6 +22,10 @@ Secret out-of-band (e.g. SealedSecret in the gitops repo).
{{- if not $kubeconfigKey }} {{- if not $kubeconfigKey }}
{{- if and $existing (hasKey $existing.data "cluster-kubeconfig-key") }}{{- $kubeconfigKey = index $existing.data "cluster-kubeconfig-key" | b64dec }}{{- else }}{{- $kubeconfigKey = randAlphaNum 32 }}{{- end }} {{- if and $existing (hasKey $existing.data "cluster-kubeconfig-key") }}{{- $kubeconfigKey = index $existing.data "cluster-kubeconfig-key" | b64dec }}{{- else }}{{- $kubeconfigKey = randAlphaNum 32 }}{{- end }}
{{- end }} {{- end }}
{{- $redisPass := .Values.redis.password }}
{{- if not $redisPass }}
{{- if and $existing (hasKey $existing.data "redis-password") }}{{- $redisPass = index $existing.data "redis-password" | b64dec }}{{- else }}{{- $redisPass = randAlphaNum 24 }}{{- end }}
{{- end }}
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -35,4 +39,5 @@ stringData:
jwt-secret: {{ $jwt | quote }} jwt-secret: {{ $jwt | quote }}
jwt-refresh-secret: {{ $jwtRefresh | quote }} jwt-refresh-secret: {{ $jwtRefresh | quote }}
cluster-kubeconfig-key: {{ $kubeconfigKey | quote }} cluster-kubeconfig-key: {{ $kubeconfigKey | quote }}
redis-password: {{ $redisPass | quote }}
{{- end }} {{- end }}
@@ -8,6 +8,11 @@ global:
storageClass: local-path # k3s example storageClass: local-path # k3s example
images: images:
# Mirror Docker Hub images through your private registry so cluster nodes
# never pull from docker.io directly (matches the kaniko/Harbor setup).
postgres: registry.example.com/mirror/postgres:16-alpine
redis: registry.example.com/mirror/redis:7-alpine
busybox: registry.example.com/mirror/busybox:1.36
backend: backend:
repository: registry.example.com/cloudhost-backend repository: registry.example.com/cloudhost-backend
tag: "1.0.0" tag: "1.0.0"
@@ -19,6 +24,15 @@ images:
postgres: postgres:
password: "CHANGE_ME_STRONG_POSTGRES_PASSWORD" password: "CHANGE_ME_STRONG_POSTGRES_PASSWORD"
# Pull secret for the mirrored postgres image
imagePullSecrets:
- name: registry-pull-secret
redis:
# Auto-generated and persisted in the platform Secret when left empty.
password: ""
imagePullSecrets:
- name: registry-pull-secret
secrets: secrets:
jwtSecret: "CHANGE_ME_LONG_JWT_SECRET" jwtSecret: "CHANGE_ME_LONG_JWT_SECRET"
@@ -44,6 +58,20 @@ backend:
PLATFORM_DOMAIN: apps.example.com PLATFORM_DOMAIN: apps.example.com
REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000 REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000
REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000 REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000
# Mirror prefix for base images in generated Dockerfiles + managed services
BASE_IMAGE_REGISTRY: registry.example.com/mirror
# Elastic log-stack credentials (must match the logging namespace Secret)
ELASTIC_PASSWORD: "CHANGE_ME_ELASTIC_PASSWORD"
FLUENTBIT_PASSWORD: "CHANGE_ME_FLUENTBIT_PASSWORD"
KIBANA_SYSTEM_PASSWORD: "CHANGE_ME_KIBANA_PASSWORD"
# Swagger stays off in production; set SWAGGER_ENABLED: "true" to expose it
migrations: migrations:
enabled: true enabled: true
backups:
postgres:
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
retentionDays: 7
+40 -5
View File
@@ -12,6 +12,9 @@ createNamespace: true
global: global:
storageClass: "" storageClass: ""
# For clusters without direct docker.io access, point these at your mirror,
# e.g. registry.abrban.com/abrban/postgres:16-alpine, and set
# postgres.imagePullSecrets / redis.imagePullSecrets accordingly.
images: images:
postgres: postgres:16-alpine postgres: postgres:16-alpine
redis: redis:7-alpine redis: redis:7-alpine
@@ -32,12 +35,31 @@ postgres:
# Leave empty to auto-generate on first install (stored in Secret) # Leave empty to auto-generate on first install (stored in Secret)
password: "" password: ""
storage: 10Gi storage: 10Gi
resources: {} # Needed when images.postgres points at a private mirror
imagePullSecrets: []
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
redis: redis:
enabled: true enabled: true
storage: 1Gi storage: 1Gi
resources: {} # Leave empty to auto-generate on first install (stored in Secret as redis-password).
# With secrets.existingSecret, that Secret must also contain a redis-password key.
password: ""
# Needed when images.redis points at a private mirror
imagePullSecrets: []
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 512Mi
backend: backend:
enabled: true enabled: true
@@ -49,7 +71,13 @@ backend:
sourceStorage: sourceStorage:
enabled: false enabled: false
existingSecret: ceph-app-sources-credentials existingSecret: ceph-app-sources-credentials
resources: {} resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
extraEnv: {} extraEnv: {}
env: env:
NODE_ENV: production NODE_ENV: production
@@ -73,7 +101,13 @@ frontend:
replicas: 1 replicas: 1
imagePullSecrets: imagePullSecrets:
- name: registry-pull-secret - name: registry-pull-secret
resources: {} resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
# JWT secrets — set in production (values-production.example.yaml) # JWT secrets — set in production (values-production.example.yaml)
secrets: secrets:
@@ -117,6 +151,7 @@ monitoring:
backups: backups:
postgres: postgres:
enabled: false enabled: true
schedule: "0 3 * * *" schedule: "0 3 * * *"
storageSize: 10Gi storageSize: 10Gi
retentionDays: 7
+10 -12
View File
@@ -9,18 +9,16 @@ metadata:
labels: labels:
app.kubernetes.io/managed-by: cloudhost app.kubernetes.io/managed-by: cloudhost
--- ---
# Elasticsearch credentials secret # Elasticsearch credentials — managed OUT-OF-BAND, never committed to git.
apiVersion: v1 # Create the Secret before applying this manifest (or use a SealedSecret in
kind: Secret # the GitOps repo):
metadata: #
name: elasticsearch-credentials # kubectl -n logging create secret generic elasticsearch-credentials \
namespace: logging # --from-literal=ELASTIC_PASSWORD="$(openssl rand -base64 24)" \
type: Opaque # --from-literal=FLUENTBIT_PASSWORD="$(openssl rand -base64 24)"
stringData: #
# Admin credentials - change in production! # The backend reads the same values from ELASTIC_PASSWORD / FLUENTBIT_PASSWORD
ELASTIC_PASSWORD: "CloudHost2024!Secure" # env vars (see cloudhost-platform values: backend.extraEnv or an extra Secret).
# For Fluent Bit to send logs
FLUENTBIT_PASSWORD: "FluentBit2024!Writer"
--- ---
# ConfigMap for Elasticsearch configuration # ConfigMap for Elasticsearch configuration
apiVersion: v1 apiVersion: v1
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,20 @@
-- Temporary external access grants (Redis, RabbitMQ, database) -- Temporary external access grants (Redis, RabbitMQ, database)
DO $$ BEGIN
CREATE TYPE service_access_target AS ENUM ( CREATE TYPE service_access_target AS ENUM (
'database', 'database',
'redis', 'redis',
'rabbitmq_amqp', 'rabbitmq_amqp',
'rabbitmq_management' 'rabbitmq_management'
); );
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE service_access_grant_status AS ENUM ( CREATE TYPE service_access_grant_status AS ENUM (
'active', 'active',
'expired', 'expired',
'revoked' 'revoked'
); );
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS service_access_grants ( CREATE TABLE IF NOT EXISTS service_access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -3,7 +3,7 @@ ALTER TABLE applications
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
CREATE INDEX IF NOT EXISTS idx_applications_user_product_type CREATE INDEX IF NOT EXISTS idx_applications_user_product_type
ON applications (user_id, product_type); ON applications ("userId", product_type);
ALTER TABLE resource_credits ALTER TABLE resource_credits
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Generate the greenfield base schema (000_base_schema.sql) by letting TypeORM
* `synchronize` build every table from the entities against a throwaway
* Postgres, then dumping the schema. Run when entities change materially:
*
* docker run -d --name ch-schemagen -e POSTGRES_PASSWORD=pass \
* -e POSTGRES_USER=cloudhost -e POSTGRES_DB=cloudhost \
* -p 55432:5432 postgres:16-alpine
* node scripts/generate-base-schema.mjs
*
* The output is wrapped so it is safe to run on an already-populated database
* (every statement uses IF NOT EXISTS / duplicate_object guards where possible;
* the migration runner also records it in schema_migrations so it runs once).
*/
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const backendRoot = path.resolve(__dirname, '..');
const outPath = path.join(backendRoot, 'migrations', '000_base_schema.sql');
// Use the COMPILED entities (run `npm run build` first) — union-typed columns
// only carry correct decorator metadata through the project's tsc build.
const ds = new DataSource({
type: 'postgres',
host: process.env.SCHEMA_DB_HOST || '127.0.0.1',
port: parseInt(process.env.SCHEMA_DB_PORT || '55432', 10),
username: 'cloudhost',
password: 'pass',
database: 'cloudhost',
entities: [path.join(backendRoot, 'dist/**/*.entity.js')],
synchronize: true,
});
await ds.initialize();
await ds.destroy();
// Dump schema-only from the container, then strip owner/ACL noise.
const dumped = execFileSync('docker', [
'exec', 'ch-schemagen',
'pg_dump', '-U', 'cloudhost', '-d', 'cloudhost',
'--schema-only', '--no-owner', '--no-privileges',
], { maxBuffer: 32 * 1024 * 1024 }).toString();
// Strip:
// - psql client meta-commands that are version-specific (\restrict is
// pg_dump 16.13+ only) and would break on the migrations image's psql;
// - the `search_path = ''` reset, which otherwise persists into the trailing
// `INSERT INTO schema_migrations` the runner appends (unqualified) and the
// footer below, causing "no schema has been selected to create in".
const raw = dumped
.split('\n')
.filter(
(line) =>
!/^\\(restrict|unrestrict)\b/.test(line) &&
!/set_config\('search_path'/.test(line),
)
.join('\n');
const header = `-- 000_base_schema.sql — greenfield base schema (generated from TypeORM entities).
-- Auto-generated by scripts/generate-base-schema.mjs. Do not edit by hand.
-- Incremental migrations (001+) run afterwards on top of this schema.
`;
// The legacy pricing-catalog migrations (004-009) target a superseded
// snake_case pricing schema that is incompatible with the current entities.
// On greenfield the base schema already creates the entity-shaped pricing
// tables and the app self-seeds their rows (PricingCatalogService.ensureDefaults
// on boot), so mark those migrations as already applied to skip them.
const supersededPricingMigrations = [
'004_pricing_catalog.sql',
'005_pricing_catalog_all_runtimes.sql',
'006_addon_rate_resources.sql',
'007_optional_service_pricing_matrix.sql',
'008_application_optional_service_resources.sql',
'009_optional_service_deploy_defaults.sql',
];
const footer = `
-- Mark superseded legacy pricing migrations as applied (see generator note).
CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
INSERT INTO schema_migrations (filename) VALUES
${supersededPricingMigrations.map((m) => ` ('${m}')`).join(',\n')}
ON CONFLICT (filename) DO NOTHING;
`;
fs.writeFileSync(outPath, header + raw + footer);
console.log(`Wrote ${outPath} (${raw.length} bytes)`);
+1
View File
@@ -53,6 +53,7 @@ import configuration from './config/configuration';
redis: { redis: {
host: configService.get('redis.host'), host: configService.get('redis.host'),
port: configService.get('redis.port'), port: configService.get('redis.port'),
password: configService.get('redis.password'),
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],
@@ -387,6 +387,29 @@ export class ApplicationsController {
throw new BadRequestException('Replicas can only be changed for the main application workload.'); throw new BadRequestException('Replicas can only be changed for the main application workload.');
} }
// Non-staff users must go through the billed upgrade flow for any change
// that increases cost — direct PATCH must not bypass payment.
if (!isStaff) {
const upgradeDto =
workload === 'app'
? {
cpuLimit: dto.cpuLimit,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
}
: workload === 'database'
? { databaseResources: { cpuLimit: dto.cpuLimit, memoryLimit: dto.memoryLimit } }
: workload === 'redis'
? { redisResources: { cpuLimit: dto.cpuLimit, memoryLimit: dto.memoryLimit } }
: { rabbitmqResources: { cpuLimit: dto.cpuLimit, memoryLimit: dto.memoryLimit } };
const cost = await this.billingService.calculateUpgradeCost(app, upgradeDto as any);
if (cost.proratedAmount > 0) {
throw new BadRequestException(
'This change increases the plan cost. Use the resource upgrade flow (with invoice payment) instead.',
);
}
}
// Update in K8s (live) // Update in K8s (live)
await this.kubernetesService.updateResources(app, dto, workload); await this.kubernetesService.updateResources(app, dto, workload);
@@ -22,6 +22,7 @@ import {
detectRuntimeFromArchive, detectRuntimeFromArchive,
} from '../build/runtime-detector'; } from '../build/runtime-detector';
import { SourceStorageService } from '../storage/source-storage.service'; import { SourceStorageService } from '../storage/source-storage.service';
import { userIdSlug } from '../kubernetes/k8s-workload.util';
import * as os from 'os'; import * as os from 'os';
@Injectable() @Injectable()
@@ -67,6 +68,18 @@ export class ApplicationsService {
dto = normalizeCreateApplicationDto(dto); dto = normalizeCreateApplicationDto(dto);
const productType = dto.productType ?? ProductType.APPLICATION; const productType = dto.productType ?? ProductType.APPLICATION;
// WordPress only runs on MySQL/MariaDB — reject PostgreSQL/Mongo/none up
// front instead of failing at runtime inside the WordPress container.
if (dto.runtime === AppRuntime.WORDPRESS) {
if (!dto.databaseType || dto.databaseType === DatabaseType.NONE) {
dto.databaseType = DatabaseType.MYSQL;
} else if (![DatabaseType.MYSQL, DatabaseType.MARIADB].includes(dto.databaseType)) {
throw new BadRequestException(
`WordPress requires a MySQL or MariaDB database — "${dto.databaseType}" is not supported.`,
);
}
}
// Placement is always decided automatically by the allocator. // Placement is always decided automatically by the allocator.
const allocation = await this.clustersService.selectClusterForApplication(dto, userId); const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
const clusterId = allocation.cluster.id; const clusterId = allocation.cluster.id;
@@ -96,7 +109,7 @@ export class ApplicationsService {
const baseLabel = dto.name; const baseLabel = dto.name;
const subdomain = customDomain const subdomain = customDomain
? `${this.toDnsLabel(baseLabel)}-${this.toDnsLabel(userId.split('-')[0])}` ? `${this.toDnsLabel(baseLabel)}-${this.toDnsLabel(userIdSlug(userId).slice(0, 12))}`
: await this.generateRandomSubdomain(baseLabel); : await this.generateRandomSubdomain(baseLabel);
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
@@ -16,6 +16,7 @@ import {
CustomDomainStatus, CustomDomainStatus,
ProductType, ProductType,
} from '../../common/enums'; } from '../../common/enums';
import { Exclude, Expose } from 'class-transformer';
import { User } from '../../users/entities/user.entity'; import { User } from '../../users/entities/user.entity';
import { Deployment } from '../../deployments/entities/deployment.entity'; import { Deployment } from '../../deployments/entities/deployment.entity';
@@ -111,8 +112,19 @@ export class Application {
@Column({ nullable: true }) @Column({ nullable: true })
gitUrl: string; gitUrl: string;
/**
* Personal access token for private repos. Never serialized into API
* responses (see hasGitToken) — it is a credential to an external system.
*/
@Exclude({ toPlainOnly: true })
@Column({ nullable: true }) @Column({ nullable: true })
gitToken: string; // Personal access token for private repos gitToken: string;
/** Whether a git token is configured (safe indicator for the UI). */
@Expose()
get hasGitToken(): boolean {
return !!this.gitToken;
}
@Column({ nullable: true }) @Column({ nullable: true })
gitBranch: string; // Branch to clone (default: main) gitBranch: string; // Branch to clone (default: main)
@@ -14,7 +14,11 @@ import {
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service'; import { BillingService } from './billing.service';
import { assertStubGatewayAllowed } from './payment-gateway.util'; import {
assertStubGatewayAllowed,
issueGatewayTrackingCode,
assertGatewayTrackingCodeValid,
} from './payment-gateway.util';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import { ApplicationsService } from '../applications/applications.service'; import { ApplicationsService } from '../applications/applications.service';
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto'; import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
@@ -43,8 +47,11 @@ export class BillingWalletController {
} }
@Post('wallet/charge') @Post('wallet/charge')
@ApiOperation({ summary: 'Charge my wallet (self top-up)' }) @ApiOperation({ summary: 'Charge my wallet (self top-up — stub gateway, dev/staging only)' })
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) { async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
// Direct self-credit is only for environments with the stub gateway enabled.
// In production a real payment gateway must credit wallets.
assertStubGatewayAllowed();
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up'); return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
} }
@@ -160,7 +167,7 @@ export class BillingWalletController {
@Body() body: { amount: number; description?: string; callbackUrl: string }, @Body() body: { amount: number; description?: string; callbackUrl: string },
) { ) {
assertStubGatewayAllowed(); assertStubGatewayAllowed();
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; const trackingCode = issueGatewayTrackingCode(req.user.id, body.amount);
return { return {
success: true, success: true,
trackingCode, trackingCode,
@@ -176,6 +183,8 @@ export class BillingWalletController {
@Body() body: { trackingCode: string; amount: number }, @Body() body: { trackingCode: string; amount: number },
) { ) {
assertStubGatewayAllowed(); assertStubGatewayAllowed();
// The tracking code binds user + amount at initiate time; reject tampered amounts.
assertGatewayTrackingCodeValid(body.trackingCode, req.user.id, body.amount);
await this.billingService.chargeWallet( await this.billingService.chargeWallet(
req.user.id, req.user.id,
body.amount, body.amount,
+55 -14
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm'; import { Repository, IsNull, MoreThan, FindOptionsWhere, EntityManager } from 'typeorm';
import { Wallet } from './entities/wallet.entity'; import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity'; import { WalletTransaction } from './entities/wallet-transaction.entity';
import { Invoice } from './entities/invoice.entity'; import { Invoice } from './entities/invoice.entity';
@@ -172,6 +172,32 @@ export class BillingService {
return { balance: Number(wallet.balance) }; return { balance: Number(wallet.balance) };
} }
/**
* Load the user's wallet inside a transaction with a row-level lock
* (SELECT ... FOR UPDATE) so concurrent charge/deduct operations serialize
* instead of racing on read-modify-write.
*/
private async lockWallet(em: EntityManager, userId: string): Promise<Wallet> {
let wallet = await em.getRepository(Wallet).findOne({
where: { userId },
lock: { mode: 'pessimistic_write' },
});
if (!wallet) {
// First-time wallet creation may race; the unique userId column makes
// one insert win — re-read with the lock afterwards.
try {
await em.getRepository(Wallet).insert({ userId, balance: 0 });
} catch {
/* concurrent insert won — fall through to locked re-read */
}
wallet = await em.getRepository(Wallet).findOneOrFail({
where: { userId },
lock: { mode: 'pessimistic_write' },
});
}
return wallet;
}
async chargeWallet( async chargeWallet(
userId: string, userId: string,
amount: number, amount: number,
@@ -180,11 +206,12 @@ export class BillingService {
): Promise<WalletTransaction> { ): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive'); if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId); const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
wallet.balance = Number(wallet.balance) + amount; wallet.balance = Number(wallet.balance) + amount;
await this.walletRepo.save(wallet); await em.getRepository(Wallet).save(wallet);
const tx = this.txRepo.create({ const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id, walletId: wallet.id,
type: TransactionType.CHARGE, type: TransactionType.CHARGE,
amount, amount,
@@ -192,9 +219,10 @@ export class BillingService {
description: description || 'Wallet charge', description: description || 'Wallet charge',
invoiceId, invoiceId,
}); });
const saved = await this.txRepo.save(tx); return em.getRepository(WalletTransaction).save(tx);
});
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${wallet.balance}`); this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${saved.balanceAfter}`);
return saved; return saved;
} }
@@ -207,15 +235,16 @@ export class BillingService {
): Promise<WalletTransaction> { ): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive'); if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId); const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
if (Number(wallet.balance) < amount) { if (Number(wallet.balance) < amount) {
throw new BadRequestException('Insufficient wallet balance'); throw new BadRequestException('Insufficient wallet balance');
} }
wallet.balance = Number(wallet.balance) - amount; wallet.balance = Number(wallet.balance) - amount;
await this.walletRepo.save(wallet); await em.getRepository(Wallet).save(wallet);
const tx = this.txRepo.create({ const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id, walletId: wallet.id,
type: TransactionType.DEDUCTION, type: TransactionType.DEDUCTION,
amount, amount,
@@ -224,9 +253,10 @@ export class BillingService {
applicationId, applicationId,
invoiceId, invoiceId,
}); });
const saved = await this.txRepo.save(tx); return em.getRepository(WalletTransaction).save(tx);
});
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${wallet.balance}`); this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${saved.balanceAfter}`);
return saved; return saved;
} }
@@ -743,7 +773,9 @@ export class BillingService {
yearly: newCost.yearly - currentCost.yearly, yearly: newCost.yearly - currentCost.yearly,
}; };
// Calculate prorated amount based on remaining time in billing period // Calculate prorated amount based on remaining time in billing period.
// Use the price difference of the app's own billing cycle scaled by the
// fraction of the cycle that remains — not the hourly rate for all cycles.
let proratedAmount = 0; let proratedAmount = 0;
let remainingHours = 0; let remainingHours = 0;
@@ -752,9 +784,18 @@ export class BillingService {
const expiresAt = new Date(app.planExpiresAt); const expiresAt = new Date(app.planExpiresAt);
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60)); remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
const cycleDifference = this.amountForCycle(difference, app.billingCycle);
const cycleHours =
app.billingCycle === BillingCycle.HOURLY
? 1
: app.billingCycle === BillingCycle.MONTHLY
? 30 * 24
: 365 * 24;
// Only charge difference if upgrading (not downgrading) // Only charge difference if upgrading (not downgrading)
if (difference.hourly > 0) { if (cycleDifference > 0) {
proratedAmount = Math.ceil(difference.hourly * remainingHours); const remainingFraction = Math.min(1, remainingHours / cycleHours);
proratedAmount = Math.ceil(cycleDifference * remainingFraction);
} }
} }
+48 -1
View File
@@ -1,4 +1,5 @@
import { ForbiddenException } from '@nestjs/common'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
/** /**
* Stub gateway endpoints auto-approve payments without a real provider. * Stub gateway endpoints auto-approve payments without a real provider.
@@ -12,3 +13,49 @@ export function assertStubGatewayAllowed(): void {
throw new ForbiddenException('Payment gateway is not configured'); throw new ForbiddenException('Payment gateway is not configured');
} }
} }
function gatewaySigningSecret(): string {
return (
process.env.PAYMENT_GATEWAY_SIGNING_SECRET ||
process.env.JWT_SECRET ||
'default-jwt-secret'
);
}
function hmacSignature(payload: string): string {
return createHmac('sha256', gatewaySigningSecret()).update(payload).digest('hex').slice(0, 24);
}
/**
* Issue a tracking code that cryptographically binds the initiating user and
* amount, so `verify` cannot be replayed with a different (larger) amount.
* Format: PAY-<ts>-<rand>-<hmac(userId|amount|ts|rand)>
*/
export function issueGatewayTrackingCode(userId: string, amount: number): string {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
const sig = hmacSignature(`${userId}|${amount}|${ts}|${rand}`);
return `PAY-${ts}-${rand}-${sig}`;
}
/**
* Validate a tracking code issued by {@link issueGatewayTrackingCode} against
* the calling user and the amount being credited. Throws on any mismatch.
*/
export function assertGatewayTrackingCodeValid(
trackingCode: string,
userId: string,
amount: number,
): void {
const parts = String(trackingCode || '').split('-');
if (parts.length !== 4 || parts[0] !== 'PAY') {
throw new BadRequestException('Invalid gateway tracking code');
}
const [, ts, rand, sig] = parts;
const expected = hmacSignature(`${userId}|${amount}|${ts}|${rand}`);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new BadRequestException('Gateway tracking code does not match the payment details');
}
}
+47
View File
@@ -4,8 +4,24 @@ import Redis from 'ioredis';
import type { BuildProgress } from './build.service'; import type { BuildProgress } from './build.service';
const KEY_PREFIX = 'build:progress:'; const KEY_PREFIX = 'build:progress:';
const SESSION_KEY_PREFIX = 'build:session:';
const TTL_SECONDS = 3600; const TTL_SECONDS = 3600;
/**
* Serializable subset of an active build session, persisted to Redis so that
* after a backend restart the orphaned cluster resources (job, PVC, secret,
* helper pod) of interrupted builds can still be located and cleaned up.
*/
export interface PersistedBuildSession {
deploymentId: string;
applicationId?: string;
namespace?: string;
buildPodName?: string;
sourcePvcName?: string;
helperPodName?: string;
gitSecretName?: string;
}
@Injectable() @Injectable()
export class BuildProgressStore implements OnModuleDestroy { export class BuildProgressStore implements OnModuleDestroy {
private readonly redis: Redis; private readonly redis: Redis;
@@ -14,6 +30,7 @@ export class BuildProgressStore implements OnModuleDestroy {
this.redis = new Redis({ this.redis = new Redis({
host: this.configService.get<string>('redis.host'), host: this.configService.get<string>('redis.host'),
port: this.configService.get<number>('redis.port'), port: this.configService.get<number>('redis.port'),
password: this.configService.get<string>('redis.password'),
lazyConnect: true, lazyConnect: true,
maxRetriesPerRequest: 1, maxRetriesPerRequest: 1,
}); });
@@ -52,6 +69,36 @@ export class BuildProgressStore implements OnModuleDestroy {
} }
} }
async setSession(session: PersistedBuildSession): Promise<void> {
try {
await this.redis.set(
`${SESSION_KEY_PREFIX}${session.deploymentId}`,
JSON.stringify(session),
'EX',
TTL_SECONDS,
);
} catch {
// Best-effort — cleanup falls back to prefix-based resource scan.
}
}
async getSession(deploymentId: string): Promise<PersistedBuildSession | null> {
try {
const raw = await this.redis.get(`${SESSION_KEY_PREFIX}${deploymentId}`);
return raw ? (JSON.parse(raw) as PersistedBuildSession) : null;
} catch {
return null;
}
}
async clearSession(deploymentId: string): Promise<void> {
try {
await this.redis.del(`${SESSION_KEY_PREFIX}${deploymentId}`);
} catch {
// ignore
}
}
onModuleDestroy(): void { onModuleDestroy(): void {
this.redis.disconnect(); this.redis.disconnect();
} }
+241 -69
View File
@@ -31,12 +31,14 @@ export class BuildCancelledError extends Error {
interface ActiveBuildSession { interface ActiveBuildSession {
cancelled: boolean; cancelled: boolean;
applicationId?: string;
coreApi?: k8s.CoreV1Api; coreApi?: k8s.CoreV1Api;
batchApi?: k8s.BatchV1Api; batchApi?: k8s.BatchV1Api;
namespace?: string; namespace?: string;
buildPodName?: string; buildPodName?: string;
sourcePvcName?: string; sourcePvcName?: string;
helperPodName?: string; helperPodName?: string;
gitSecretName?: string;
processes: ChildProcess[]; processes: ChildProcess[];
socket?: net.Socket; socket?: net.Socket;
} }
@@ -68,8 +70,71 @@ export class BuildService {
private sourceStorage: SourceStorageService, private sourceStorage: SourceStorageService,
) {} ) {}
private beginBuildSession(deploymentId: string): void { /**
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] }); * Prefix Docker Hub base images with the configured mirror registry
* (BASE_IMAGE_REGISTRY), so generated Dockerfiles work on clusters that
* cannot reach docker.io. Images already pinned to another registry
* (gcr.io, mcr.microsoft.com, …) are returned unchanged.
*/
private baseImage(image: string): string {
const prefix = this.configService.get<string>('build.baseImageRegistry');
if (!prefix) return image;
const firstSegment = image.split('/')[0];
const hasRegistry = firstSegment.includes('.') || firstSegment.includes(':');
if (hasRegistry) return image;
return `${prefix}/${image}`;
}
/**
* Git branch names come from users and end up in a shell command — accept
* only conservative ref characters and reject anything option-like.
*/
private assertSafeGitBranch(branch: string): string {
const b = (branch || '').trim();
if (!b || b.length > 255 || b.startsWith('-') || b.includes('..') || !/^[A-Za-z0-9._/-]+$/.test(b)) {
throw new Error(`Invalid git branch name: "${branch}"`);
}
return b;
}
/**
* SSRF guard for user-supplied repo URLs: only http(s), no embedded
* credentials, and no loopback/link-local/private or cluster-internal hosts.
*/
private assertSafeGitUrl(gitUrl: string): void {
let url: URL;
try {
url = new URL(gitUrl);
} catch {
throw new Error(`Invalid git URL: "${gitUrl}"`);
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error(`Unsupported git URL protocol: "${url.protocol}" — only http(s) is allowed`);
}
if (url.username || url.password) {
throw new Error('Git URL must not contain embedded credentials — use the git token field instead');
}
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
const blockedHosts = ['localhost', 'metadata.google.internal'];
const blockedSuffixes = ['.local', '.localhost', '.internal', '.svc', '.svc.cluster.local', '.cluster.local'];
const isPrivateIPv4 =
/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
const isIPv6Internal = host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd');
if (
blockedHosts.includes(host) ||
blockedSuffixes.some((s) => host.endsWith(s)) ||
isPrivateIPv4 ||
isIPv6Internal ||
!host.includes('.')
) {
throw new Error(`Git URL host "${url.hostname}" is not allowed`);
}
}
private beginBuildSession(deploymentId: string, applicationId?: string): void {
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [], applicationId });
this.persistSession(deploymentId);
} }
private getSession(deploymentId?: string): ActiveBuildSession | undefined { private getSession(deploymentId?: string): ActiveBuildSession | undefined {
@@ -80,6 +145,26 @@ export class BuildService {
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void { private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
const session = this.activeBuilds.get(deploymentId); const session = this.activeBuilds.get(deploymentId);
if (session) Object.assign(session, update); if (session) Object.assign(session, update);
this.persistSession(deploymentId);
}
/**
* Mirror the serializable part of the session to Redis, so interrupted
* builds can be detected and their cluster resources cleaned up after a
* backend restart (the in-memory map does not survive restarts).
*/
private persistSession(deploymentId: string): void {
const session = this.activeBuilds.get(deploymentId);
if (!session) return;
void this.progressStore.setSession({
deploymentId,
applicationId: session.applicationId,
namespace: session.namespace,
buildPodName: session.buildPodName,
sourcePvcName: session.sourcePvcName,
helperPodName: session.helperPodName,
gitSecretName: session.gitSecretName,
});
} }
private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void { private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void {
@@ -122,7 +207,10 @@ export class BuildService {
} }
private endBuildSession(deploymentId?: string): void { private endBuildSession(deploymentId?: string): void {
if (deploymentId) this.activeBuilds.delete(deploymentId); if (deploymentId) {
this.activeBuilds.delete(deploymentId);
void this.progressStore.clearSession(deploymentId);
}
} }
async cancelBuild(deploymentId: string): Promise<void> { async cancelBuild(deploymentId: string): Promise<void> {
@@ -154,7 +242,7 @@ export class BuildService {
} }
} }
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session; const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName, gitSecretName } = session;
if (coreApi && namespace) { if (coreApi && namespace) {
const cleanup: Promise<unknown>[] = []; const cleanup: Promise<unknown>[] = [];
if (helperPodName) { if (helperPodName) {
@@ -200,6 +288,11 @@ export class BuildService {
.catch(() => undefined), .catch(() => undefined),
); );
} }
if (gitSecretName) {
cleanup.push(
coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace }).catch(() => undefined),
);
}
await Promise.all(cleanup); await Promise.all(cleanup);
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`); this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
} }
@@ -209,7 +302,7 @@ export class BuildService {
percent: 0, percent: 0,
message: 'Cancelled by user', message: 'Cancelled by user',
}); });
this.activeBuilds.delete(deploymentId); this.endBuildSession(deploymentId);
} }
/** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */ /** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */
@@ -226,13 +319,14 @@ export class BuildService {
const cleanup: Promise<unknown>[] = []; const cleanup: Promise<unknown>[] = [];
const [pods, pvcs, jobs, configMaps] = await Promise.all([ const [pods, pvcs, jobs, configMaps, secrets] = await Promise.all([
coreApi.listNamespacedPod({ namespace: buildNamespace }), coreApi.listNamespacedPod({ namespace: buildNamespace }),
coreApi.listNamespacedPersistentVolumeClaim({ coreApi.listNamespacedPersistentVolumeClaim({
namespace: buildNamespace, namespace: buildNamespace,
}), }),
batchApi.listNamespacedJob({ namespace: buildNamespace }), batchApi.listNamespacedJob({ namespace: buildNamespace }),
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }), coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
coreApi.listNamespacedSecret({ namespace: buildNamespace }),
]); ]);
for (const pod of pods.items) { for (const pod of pods.items) {
@@ -283,6 +377,12 @@ export class BuildService {
cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined)); cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined));
} }
} }
for (const secret of secrets.items) {
const name = secret.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedSecret({ name, namespace: buildNamespace }).catch(() => undefined));
}
}
await Promise.all(cleanup); await Promise.all(cleanup);
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`); this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
@@ -320,7 +420,7 @@ export class BuildService {
this.logger.log(`Starting image build for ${app.name}${imageUri}`); this.logger.log(`Starting image build for ${app.name}${imageUri}`);
if (deploymentId) { if (deploymentId) {
this.beginBuildSession(deploymentId); this.beginBuildSession(deploymentId, app.id);
} }
const hasUploadedCode = !!app.codePath; const hasUploadedCode = !!app.codePath;
@@ -389,6 +489,8 @@ export class BuildService {
// If we have uploaded code, create a PVC and upload via kubectl cp // If we have uploaded code, create a PVC and upload via kubectl cp
let sourcePvcName: string | undefined; let sourcePvcName: string | undefined;
// Secret holding the git token for private-repo clones (created lazily)
let gitSecretName: string | undefined;
if (hasUploadedCode && localZipPath) { if (hasUploadedCode && localZipPath) {
sourcePvcName = `${buildPodName}-source`; sourcePvcName = `${buildPodName}-source`;
if (deploymentId) { if (deploymentId) {
@@ -446,7 +548,7 @@ export class BuildService {
// Add init container that unzips the source code from PVC // Add init container that unzips the source code from PVC
initContainers.push({ initContainers.push({
name: 'unzip-source', name: 'unzip-source',
image: 'alpine:3.19', image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
command: [ command: [
'sh', 'sh',
@@ -493,36 +595,60 @@ export class BuildService {
], ],
}); });
} else if (hasGitUrl) { } else if (hasGitUrl) {
// Build the git clone URL — inject token for private repos // Validate user-controlled values before they get anywhere near a shell.
let cloneUrl = app.gitUrl!; this.assertSafeGitUrl(app.gitUrl!);
const branch = this.assertSafeGitBranch(app.gitBranch || 'main');
// The token never appears in the command line or the clone URL — it is
// delivered via a Secret env var and handed to git through GIT_ASKPASS,
// so it can't leak through pod specs, `ps`, or job logs.
if (app.gitToken) { if (app.gitToken) {
// Convert https://github.com/user/repo.git → https://<token>@github.com/user/repo.git gitSecretName = `${buildPodName}-git`;
// Also works for GitLab, Bitbucket, etc. if (deploymentId) this.updateBuildSession(deploymentId, { gitSecretName });
try { await coreApi.createNamespacedSecret({
const url = new URL(cloneUrl); namespace: buildNamespace!,
url.username = app.gitToken; body: {
url.password = ''; // Some providers use token as username, others as password apiVersion: 'v1',
cloneUrl = url.toString(); kind: 'Secret',
} catch { metadata: { name: gitSecretName, namespace: buildNamespace },
// If URL parsing fails, try simple injection after protocol type: 'Opaque',
cloneUrl = cloneUrl.replace('https://', `https://${app.gitToken}@`); stringData: { GIT_TOKEN: app.gitToken },
},
});
} }
}
const branch = app.gitBranch || 'main';
// Clone git repo into /workspace/source, then copy our generated Dockerfile // Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({ initContainers.push({
name: 'git-clone', name: 'git-clone',
image: 'alpine/git:2.43.0', image: this.baseImage('alpine/git:2.43.0'),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
env: [
{ name: 'GIT_URL', value: app.gitUrl! },
{ name: 'GIT_BRANCH', value: branch },
...(gitSecretName
? [
{
name: 'GIT_TOKEN',
valueFrom: { secretKeyRef: { name: gitSecretName, key: 'GIT_TOKEN' } },
},
]
: []),
],
command: [ command: [
'sh', 'sh',
'-c', '-c',
` `
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" && set -e
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source && if [ -n "\${GIT_TOKEN:-}" ]; then
cp /dockerfile/Dockerfile /workspace-out/Dockerfile && printf '#!/bin/sh\\necho "$GIT_TOKEN"\\n' > /tmp/git-askpass.sh
echo ">>> Workspace contents:" && chmod +x /tmp/git-askpass.sh
export GIT_ASKPASS=/tmp/git-askpass.sh
export GIT_TERMINAL_PROMPT=0
fi
echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL"
git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source
cp /dockerfile/Dockerfile /workspace-out/Dockerfile
echo ">>> Workspace contents:"
ls -la /workspace-out/source/ ls -la /workspace-out/source/
`, `,
], ],
@@ -545,7 +671,7 @@ export class BuildService {
// add an init container that creates empty source dir + copies Dockerfile // add an init container that creates empty source dir + copies Dockerfile
initContainers.push({ initContainers.push({
name: 'prepare-workspace', name: 'prepare-workspace',
image: 'alpine:3.19', image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
command: [ command: [
'sh', 'sh',
@@ -586,8 +712,14 @@ export class BuildService {
args: kanikoArgs, args: kanikoArgs,
volumeMounts: kanikoVolumeMounts, volumeMounts: kanikoVolumeMounts,
resources: { resources: {
requests: { cpu: '500m', memory: '1Gi' }, requests: {
limits: { cpu: '2', memory: '4Gi' }, cpu: this.configService.get<string>('build.kaniko.cpuRequest') || '500m',
memory: this.configService.get<string>('build.kaniko.memoryRequest') || '1Gi',
},
limits: {
cpu: this.configService.get<string>('build.kaniko.cpuLimit') || '2',
memory: this.configService.get<string>('build.kaniko.memoryLimit') || '4Gi',
},
}, },
}, },
], ],
@@ -666,6 +798,17 @@ export class BuildService {
} catch (e: any) { } catch (e: any) {
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
} }
// Clean up git-token Secret
if (gitSecretName) {
try {
await coreApi.deleteNamespacedSecret({
name: gitSecretName,
namespace: buildNamespace!,
});
} catch (e: any) {
this.logger.warn(`Failed to clean up git Secret: ${e.message}`);
}
}
this.endBuildSession(deploymentId); this.endBuildSession(deploymentId);
cleanupSource?.(); cleanupSource?.();
} }
@@ -780,6 +923,8 @@ export class BuildService {
metadata: { name: pvcName, namespace }, metadata: { name: pvcName, namespace },
spec: { spec: {
accessModes: ['ReadWriteOnce'], accessModes: ['ReadWriteOnce'],
// Explicit StorageClass — don't rely on a cluster default existing
storageClassName: this.configService.get<string>('platform.storageClass') || undefined,
resources: { requests: { storage: `${sizeGi}Gi` } }, resources: { requests: { storage: `${sizeGi}Gi` } },
}, },
}, },
@@ -797,7 +942,7 @@ export class BuildService {
containers: [ containers: [
{ {
name: 'helper', name: 'helper',
image: 'alpine:3.19', image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', 'sleep 3600'], command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'source', mountPath: '/data' }], volumeMounts: [{ name: 'source', mountPath: '/data' }],
@@ -1012,10 +1157,12 @@ export class BuildService {
const port = app.port || 3000; const port = app.port || 3000;
const nodeVersion = app.runtimeVersion || '20'; const nodeVersion = app.runtimeVersion || '20';
return `# --- Build stage --- return `# --- Build stage ---
FROM node:${nodeVersion}-alpine AS builder FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --legacy-peer-deps && npm cache clean --force # Reproducible install from the lockfile when present
RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\
&& npm cache clean --force
COPY . . COPY . .
# Auto-detect Next.js and enable standalone output # Auto-detect Next.js and enable standalone output
@@ -1031,13 +1178,19 @@ RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\
break; \\ break; \\
done done
RUN npm run build || echo ">>> Build script failed or not found — continuing" # Run the build script when one exists — and FAIL the image build if it fails,
# instead of silently shipping a broken image.
RUN if node -e "const s=(require('./package.json').scripts||{});process.exit(s.build?0:1)"; then \\
echo ">>> Running build script" && npm run build; \\
else \\
echo ">>> No build script defined — skipping"; \\
fi
# Clean up dev dependencies and caches to reduce image size # Clean up dev dependencies and caches to reduce image size
RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true
# --- Production stage --- # --- Production stage ---
FROM node:${nodeVersion}-alpine AS runner FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS runner
WORKDIR /app WORKDIR /app
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
@@ -1070,9 +1223,9 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv
const phpVersion = app.phpVersion || '8.3'; const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80; const port = app.port || 80;
return `# --- Build stage (match production PHP version for Composer) --- return `# --- Build stage (match production PHP version for Composer) ---
FROM php:${phpVersion}-cli-alpine AS composer FROM ${this.baseImage(`php:${phpVersion}-cli-alpine`)} AS composer
RUN apk add --no-cache git unzip RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=${this.baseImage('composer:2')} /usr/bin/composer /usr/bin/composer
WORKDIR /app WORKDIR /app
COPY composer.json composer.lock* ./ COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
@@ -1080,11 +1233,15 @@ COPY . .
RUN composer dump-autoload --optimize --no-dev --no-scripts RUN composer dump-autoload --optimize --no-dev --no-scripts
# --- Production stage --- # --- Production stage ---
FROM php:${phpVersion}-fpm-alpine FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
RUN apk add --no-cache nginx supervisor curl openssl \\ # Laravel needs bcmath/gd/intl/zip beyond the built-in set; pdo_pgsql is built
&& docker-php-ext-install pdo pdo_mysql opcache \\ # properly against libpq instead of being silently skipped.
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true RUN apk add --no-cache nginx supervisor curl openssl icu-libs libzip libpng libjpeg-turbo freetype postgresql-libs \\
&& apk add --no-cache --virtual .build-deps icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev postgresql-dev \\
&& docker-php-ext-configure gd --with-jpeg --with-freetype \\
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache bcmath zip gd intl exif pcntl \\
&& apk del .build-deps
WORKDIR /var/www/html WORKDIR /var/www/html
COPY --from=composer /app . COPY --from=composer /app .
@@ -1163,7 +1320,7 @@ CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"]
const phpVersion = app.phpVersion || '8.3'; const phpVersion = app.phpVersion || '8.3';
const hasUploadedCode = !!app.codePath; const hasUploadedCode = !!app.codePath;
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache return `FROM ${this.baseImage(`wordpress:${wpVersion}-php${phpVersion}-apache`)}
# Install additional PHP extensions commonly needed by WordPress # Install additional PHP extensions commonly needed by WordPress
RUN docker-php-ext-install opcache RUN docker-php-ext-install opcache
@@ -1281,7 +1438,7 @@ CMD []`
const port = app.port || 8080; const port = app.port || 8080;
const buildTarget = detectGoBuildTarget(archiveEntries); const buildTarget = detectGoBuildTarget(archiveEntries);
return `# --- Build stage --- return `# --- Build stage ---
FROM golang:${goVersion}-alpine AS builder FROM ${this.baseImage(`golang:${goVersion}-alpine`)} AS builder
WORKDIR /app WORKDIR /app
# Install git for fetching dependencies # Install git for fetching dependencies
@@ -1297,8 +1454,13 @@ COPY . .
# Build the application # Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget} RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget}
# Collect optional runtime asset dirs — COPY has no shell so "|| true" is not
# valid there; stage them in the builder instead.
RUN mkdir -p /assets \\
&& for d in static templates public; do [ -d "$d" ] && cp -r "$d" /assets/ || true; done
# --- Production stage --- # --- Production stage ---
FROM alpine:3.19 FROM ${this.baseImage('alpine:3.19')}
WORKDIR /app WORKDIR /app
# Add CA certificates for HTTPS requests # Add CA certificates for HTTPS requests
@@ -1307,11 +1469,9 @@ RUN apk --no-cache add ca-certificates tzdata
# Create non-root user # Create non-root user
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
# Copy the binary from builder # Copy the binary and any staged asset dirs from the builder
COPY --from=builder /app/main . COPY --from=builder /app/main .
COPY --from=builder /app/static ./static 2>/dev/null || true COPY --from=builder /assets/ ./
COPY --from=builder /app/templates ./templates 2>/dev/null || true
COPY --from=builder /app/public ./public 2>/dev/null || true
# Create data directory for persistent storage # Create data directory for persistent storage
RUN mkdir -p /app/data && chown -R appuser:appgroup /app RUN mkdir -p /app/data && chown -R appuser:appgroup /app
@@ -1331,16 +1491,14 @@ CMD ["./main"]
private phpDockerfile(app: Application): string { private phpDockerfile(app: Application): string {
const phpVersion = app.phpVersion || '8.3'; const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80; const port = app.port || 80;
return `FROM php:${phpVersion}-fpm-alpine return `FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
RUN apk add --no-cache nginx supervisor curl \\ # Install common PHP extensions (pdo_pgsql built properly against libpq)
&& docker-php-ext-install pdo pdo_mysql opcache \\ RUN apk add --no-cache nginx supervisor curl postgresql-libs libpng libjpeg-turbo freetype \\
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true && apk add --no-cache --virtual .build-deps postgresql-dev libpng-dev libjpeg-turbo-dev freetype-dev \\
# Install common PHP extensions
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\
&& docker-php-ext-configure gd --with-freetype --with-jpeg \\ && docker-php-ext-configure gd --with-freetype --with-jpeg \\
&& docker-php-ext-install gd && docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache gd \\
&& apk del .build-deps
WORKDIR /var/www/html WORKDIR /var/www/html
COPY . . COPY . .
@@ -1401,7 +1559,7 @@ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
const pythonVersion = app.runtimeVersion || '3.12'; const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000; const port = app.port || 8000;
return `# --- Build stage --- return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app WORKDIR /app
# Install build dependencies # Install build dependencies
@@ -1409,13 +1567,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\ build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies # Install dependencies from requirements.txt or pyproject.toml. A failing
COPY requirements.txt* ./ # install FAILS the build — no silent fallback that hides missing deps.
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\ COPY . .
pip install --no-cache-dir --user flask gunicorn RUN if [ -f requirements.txt ]; then \\
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
elif [ -f pyproject.toml ]; then \\
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
else \\
echo ">>> No requirements.txt or pyproject.toml — installing default flask+gunicorn" \\
&& pip install --no-cache-dir --user flask gunicorn; \\
fi
# --- Production stage --- # --- Production stage ---
FROM python:${pythonVersion}-slim FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app WORKDIR /app
# Install runtime dependencies # Install runtime dependencies
@@ -1455,21 +1620,28 @@ CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicor
const port = app.port || 8000; const port = app.port || 8000;
const settingsModule = detectDjangoSettingsModule(archiveEntries); const settingsModule = detectDjangoSettingsModule(archiveEntries);
return `# --- Build stage --- return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app WORKDIR /app
# Install build dependencies # Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\ RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\ build-essential libpq-dev default-libmysqlclient-dev pkg-config \\
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies # Install dependencies from requirements.txt or pyproject.toml. A failing
COPY requirements.txt* ./ # install FAILS the build — no silent fallback that hides missing deps.
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\ COPY . .
pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient RUN if [ -f requirements.txt ]; then \\
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
elif [ -f pyproject.toml ]; then \\
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
else \\
echo ">>> No requirements.txt or pyproject.toml — installing Django defaults" \\
&& pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient; \\
fi
# --- Production stage --- # --- Production stage ---
FROM python:${pythonVersion}-slim FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app WORKDIR /app
# Install runtime dependencies # Install runtime dependencies
+15
View File
@@ -82,6 +82,7 @@ export default () => ({
redis: { redis: {
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10), port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
}, },
cluster: { cluster: {
@@ -133,6 +134,20 @@ export default () => ({
build: { build: {
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds', namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder', serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
/**
* Optional registry prefix for Docker Hub base images used in generated
* Dockerfiles and managed-service charts (e.g. "mirror.example.com" makes
* `node:20-alpine` `mirror.example.com/node:20-alpine`). Useful when
* cluster nodes cannot reach docker.io directly.
*/
baseImageRegistry: (process.env.BASE_IMAGE_REGISTRY || '').trim().replace(/\/+$/, ''),
/** Kaniko build container resources — tune for large images. */
kaniko: {
cpuRequest: process.env.KANIKO_CPU_REQUEST || '500m',
cpuLimit: process.env.KANIKO_CPU_LIMIT || '2',
memoryRequest: process.env.KANIKO_MEMORY_REQUEST || '1Gi',
memoryLimit: process.env.KANIKO_MEMORY_LIMIT || '4Gi',
},
}, },
elasticsearch: { elasticsearch: {
@@ -28,12 +28,24 @@ describe('validateProductionConfig', () => {
expect(() => validateProductionConfig()).toThrow(/CLUSTER_KUBECONFIG_KEY/); expect(() => validateProductionConfig()).toThrow(/CLUSTER_KUBECONFIG_KEY/);
}); });
it('throws in production when elastic credentials keep the well-known defaults', () => {
process.env.NODE_ENV = 'production';
process.env.JWT_SECRET = 'a-very-long-random-production-secret';
process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret';
process.env.DB_PASSWORD = 'strong-db-password-here';
process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef';
process.env.ELASTIC_PASSWORD = 'CloudHost2024!Secure';
expect(() => validateProductionConfig()).toThrow(/ELASTIC_PASSWORD/);
});
it('passes in production with strong secrets', () => { it('passes in production with strong secrets', () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
process.env.JWT_SECRET = 'a-very-long-random-production-secret'; process.env.JWT_SECRET = 'a-very-long-random-production-secret';
process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret'; process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret';
process.env.DB_PASSWORD = 'strong-db-password-here'; process.env.DB_PASSWORD = 'strong-db-password-here';
process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef'; process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef';
process.env.ELASTIC_PASSWORD = 'a-strong-rotated-elastic-password';
expect(() => validateProductionConfig()).not.toThrow(); expect(() => validateProductionConfig()).not.toThrow();
}); });
@@ -25,6 +25,14 @@ export function validateProductionConfig(): void {
if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) { if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) {
errors.push('CLUSTER_KUBECONFIG_KEY must be set in production to encrypt stored kubeconfigs'); errors.push('CLUSTER_KUBECONFIG_KEY must be set in production to encrypt stored kubeconfigs');
} }
// Elastic log-stack credentials must not fall back to the well-known dev defaults.
const elasticDefaults = ['CloudHost2024!Secure', 'FluentBit2024!Writer', 'Kibana2024!System'];
if (!process.env.ELASTIC_PASSWORD || elasticDefaults.includes(process.env.ELASTIC_PASSWORD)) {
errors.push('ELASTIC_PASSWORD must be set to a strong random value in production');
}
if (process.env.FLUENTBIT_PASSWORD && elasticDefaults.includes(process.env.FLUENTBIT_PASSWORD)) {
errors.push('FLUENTBIT_PASSWORD must be changed from the default in production');
}
if (errors.length > 0) { if (errors.length > 0) {
throw new Error( throw new Error(
+59 -9
View File
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import * as fs from 'fs'; import * as fs from 'fs';
import { Deployment } from './entities/deployment.entity'; import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service'; import { ApplicationsService } from '../applications/applications.service';
@@ -16,7 +16,7 @@ import {
import { ClustersService } from '../clusters/clusters.service'; import { ClustersService } from '../clusters/clusters.service';
@Injectable() @Injectable()
export class DeploymentsService { export class DeploymentsService implements OnModuleInit {
private readonly logger = new Logger(DeploymentsService.name); private readonly logger = new Logger(DeploymentsService.name);
constructor( constructor(
@@ -29,6 +29,46 @@ export class DeploymentsService {
private clustersService: ClustersService, private clustersService: ClustersService,
) {} ) {}
/**
* Recover from a backend restart: any deployment still marked as in-flight
* belonged to a pipeline running in the old process and will never finish.
* Mark it failed and best-effort clean up its orphaned build resources
* (Kaniko job, source PVC, helper pod, git secret) in the cluster.
*/
onModuleInit(): void {
void this.failInterruptedDeployments().catch((err) =>
this.logger.error('Failed to recover interrupted deployments on startup', err),
);
}
private async failInterruptedDeployments(): Promise<void> {
const stuck = await this.deploymentsRepository.find({
where: {
status: In([DeploymentStatus.PENDING, DeploymentStatus.BUILDING, DeploymentStatus.DEPLOYING]),
},
});
if (stuck.length === 0) return;
this.logger.warn(`Found ${stuck.length} deployment(s) interrupted by a restart — marking as failed`);
for (const deployment of stuck) {
await this.deploymentsRepository.update(deployment.id, {
status: DeploymentStatus.FAILED,
errorMessage: 'Build interrupted by a platform restart — please redeploy',
});
this.buildService.setProgress(deployment.id, {
phase: 'failed',
percent: 0,
message: 'Build interrupted by a platform restart',
});
try {
const app = await this.applicationsService.findOne(deployment.applicationId);
if (app) await this.buildService.cleanupBuildResourcesForApp(app);
} catch (err: any) {
this.logger.warn(`Cleanup of interrupted deployment ${deployment.id} failed: ${err.message}`);
}
}
}
/** /**
* Random 7-digit suffix for the preview host: <userId>-<7-digit>.<baseDomain>. * Random 7-digit suffix for the preview host: <userId>-<7-digit>.<baseDomain>.
* Generated once per application (see resolvePreviewNumber) and persisted. * Generated once per application (see resolvePreviewNumber) and persisted.
@@ -58,6 +98,8 @@ export class DeploymentsService {
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> { async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId); const app = await this.applicationsService.findOne(applicationId, userId);
this.ensureAppPaidAndActive(app, 'deploying');
// Create deployment record // Create deployment record
const deployment = this.deploymentsRepository.create({ const deployment = this.deploymentsRepository.create({
applicationId: app.id, applicationId: app.id,
@@ -451,15 +493,20 @@ export class DeploymentsService {
return app.latestImageTag === MANAGED_DEPLOY_MARKER; return app.latestImageTag === MANAGED_DEPLOY_MARKER;
} }
private ensureRedeployAllowed(app: any): void { /**
if (!app.billingCycle) return; * All deploy/start/redeploy operations require the app to be paid for:
* activated (billingCycle set via wallet/pay), lifecycle ACTIVE, and paid
* time remaining. Prevents deploying/resuming without payment.
*/
private ensureAppPaidAndActive(app: any, action = 'deploying'): void {
const isActive = app.lifecycleStatus === AppLifecycleStatus.ACTIVE; const isActive = app.lifecycleStatus === AppLifecycleStatus.ACTIVE;
const expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null; const expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null;
const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date(); const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date();
if (!isActive || !hasPaidTimeRemaining) { if (!app.billingCycle || !isActive || !hasPaidTimeRemaining) {
throw new BadRequestException('Payment must be completed successfully before redeploying this application.'); throw new BadRequestException(
`Payment must be completed successfully before ${action} this application.`,
);
} }
} }
@@ -636,6 +683,9 @@ export class DeploymentsService {
async startDeployment(applicationId: string, userId: string): Promise<Deployment | null> { async startDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
const app = await this.applicationsService.findOne(applicationId, userId); const app = await this.applicationsService.findOne(applicationId, userId);
// Prevent resuming a billing-suspended/expired app without payment —
// otherwise `start` bypasses the lifecycle suspension entirely.
this.ensureAppPaidAndActive(app, 'starting');
await this.kubernetesService.resumeApplication(app); await this.kubernetesService.resumeApplication(app);
await this.applicationsService.clearSuspendedReplicas(app.id); await this.applicationsService.clearSuspendedReplicas(app.id);
@@ -671,7 +721,7 @@ export class DeploymentsService {
throw new NotFoundException('No source code available. Upload code or set a git URL first.'); throw new NotFoundException('No source code available. Upload code or set a git URL first.');
} }
this.ensureRedeployAllowed(app); this.ensureAppPaidAndActive(app, 'redeploying');
// Create new deployment record // Create new deployment record
const deployment = this.deploymentsRepository.create({ const deployment = this.deploymentsRepository.create({
@@ -5,6 +5,7 @@ import * as crypto from 'crypto';
import { ChildProcess, spawn } from 'child_process'; import { ChildProcess, spawn } from 'child_process';
import { ClustersService } from '../clusters/clusters.service'; import { ClustersService } from '../clusters/clusters.service';
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service'; import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
import { userNamespace } from './k8s-workload.util';
interface ElasticsearchCredentials { interface ElasticsearchCredentials {
username: string; username: string;
@@ -97,9 +98,9 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private configService: ConfigService, private configService: ConfigService,
private helmService: HelmService, private helmService: HelmService,
) { ) {
this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure'; this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || '';
this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer'; this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || '';
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System'; this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || '';
} }
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -649,7 +650,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
generateUserCredentials(userId: string): ElasticsearchCredentials { generateUserCredentials(userId: string): ElasticsearchCredentials {
const hash = crypto.createHash('sha256').update(`${userId}-${this.ELASTIC_PASSWORD}`).digest('hex'); const hash = crypto.createHash('sha256').update(`${userId}-${this.ELASTIC_PASSWORD}`).digest('hex');
return { return {
username: `user-${userId.split('-')[0]}`, username: userNamespace(userId),
password: hash.substring(0, 24), password: hash.substring(0, 24),
}; };
} }
@@ -666,16 +667,16 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
* Get index pattern for a user's applications * Get index pattern for a user's applications
*/ */
getIndexPattern(userId: string): string { getIndexPattern(userId: string): string {
const userPrefix = userId.split('-')[0]; return `logs-${userNamespace(userId)}-*`;
return `logs-user-${userPrefix}-*`;
} }
/** /**
* Build must clauses for user log isolation (new + legacy fields). * Build must clauses for user log isolation (new + legacy fields).
*/ */
buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] { buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] {
const userPrefix = userId.split('-')[0]; // Full-UUID namespace — a truncated prefix would match other tenants'
const namespace = `user-${userPrefix}`; // namespaces and leak their logs.
const namespace = userNamespace(userId);
const must: any[] = [ const must: any[] = [
{ {
@@ -758,7 +759,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
} }
getUserIndexPattern(userId: string): string { getUserIndexPattern(userId: string): string {
return `logs-user-${userId.split('-')[0]}-*`; return `logs-${userNamespace(userId)}-*`;
} }
private elasticsearchFetch(url: string, auth: string, body: unknown): Promise<Response> { private elasticsearchFetch(url: string, auth: string, body: unknown): Promise<Response> {
@@ -125,11 +125,11 @@ describe('KubernetesService — k8s v1 client shape', () => {
expect(logs).toBe('hello logs'); expect(logs).toBe('hello logs');
const listArg = coreApi.listNamespacedPod.mock.calls[0][0]; const listArg = coreApi.listNamespacedPod.mock.calls[0][0];
expect(listArg).toMatchObject({ namespace: 'user-abc123' }); expect(listArg).toMatchObject({ namespace: 'user-abc123def456' });
expect(typeof listArg.labelSelector).toBe('string'); expect(typeof listArg.labelSelector).toBe('string');
const logArg = coreApi.readNamespacedPodLog.mock.calls[0][0]; const logArg = coreApi.readNamespacedPodLog.mock.calls[0][0];
expect(logArg).toMatchObject({ name: 'pod-1', namespace: 'user-abc123', tailLines: 200 }); expect(logArg).toMatchObject({ name: 'pod-1', namespace: 'user-abc123def456', tailLines: 200 });
}); });
it('getDatabasePvcSize reads the PVC with v1 object args and unwrapped spec', async () => { it('getDatabasePvcSize reads the PVC with v1 object args and unwrapped spec', async () => {
@@ -144,7 +144,7 @@ describe('KubernetesService — k8s v1 client shape', () => {
expect(size).toBe('5Gi'); expect(size).toBe('5Gi');
const arg = coreApi.readNamespacedPersistentVolumeClaim.mock.calls[0][0]; const arg = coreApi.readNamespacedPersistentVolumeClaim.mock.calls[0][0];
expect(arg).toMatchObject({ name: 'my-app-db', namespace: 'user-abc123' }); expect(arg).toMatchObject({ name: 'my-app-db', namespace: 'user-abc123def456' });
}); });
it('scaleDeployment patches with the v1 object body and a header-options 2nd arg', async () => { it('scaleDeployment patches with the v1 object body and a header-options 2nd arg', async () => {
@@ -157,7 +157,7 @@ describe('KubernetesService — k8s v1 client shape', () => {
const [param, options] = appsApi.patchNamespacedDeployment.mock.calls[0]; const [param, options] = appsApi.patchNamespacedDeployment.mock.calls[0];
expect(param).toMatchObject({ expect(param).toMatchObject({
name: 'my-app', name: 'my-app',
namespace: 'user-abc123', namespace: 'user-abc123def456',
body: { spec: { replicas: 3 } }, body: { spec: { replicas: 3 } },
}); });
// v1 takes the merge-patch content-type via the 2nd ConfigurationOptions arg // v1 takes the merge-patch content-type via the 2nd ConfigurationOptions arg
+11 -2
View File
@@ -1,9 +1,18 @@
import { Application } from '../applications/entities/application.entity'; import { Application } from '../applications/entities/application.entity';
import { DatabaseType, isManagedProductType } from '../common/enums'; import { DatabaseType, isManagedProductType } from '../common/enums';
/** Kubernetes namespace for a user's applications. */ /**
* Collision-free slug for a user id: the full UUID with dashes stripped
* (32 hex chars). Never truncate the UUID truncated prefixes collide
* between users and break tenant isolation (shared namespaces/logs).
*/
export function userIdSlug(userId: string): string {
return userId.replace(/-/g, '');
}
/** Kubernetes namespace for a user's applications ("user-" + 32 chars ≤ 63). */
export function userNamespace(userId: string): string { export function userNamespace(userId: string): string {
return `user-${userId.split('-')[0]}`; return `user-${userIdSlug(userId)}`;
} }
/** Primary pod label selector target for an application workload. */ /** Primary pod label selector target for an application workload. */
@@ -15,7 +15,7 @@ describe('buildHelmValues logic', () => {
return { return {
app: { app: {
name: app.name, name: app.name,
namespace: `user-${app.userId.split('-')[0]}`, namespace: `user-${app.userId.replace(/-/g, '')}`,
runtime: app.runtime, runtime: app.runtime,
image: imageUri, image: imageUri,
port: app.port, port: app.port,
@@ -72,7 +72,7 @@ describe('buildHelmValues logic', () => {
it('should set correct namespace from userId', () => { it('should set correct namespace from userId', () => {
const values = buildHelmValues(baseApp, 'registry/my-app:123'); const values = buildHelmValues(baseApp, 'registry/my-app:123');
expect(values.app.namespace).toBe('user-abc123'); expect(values.app.namespace).toBe('user-abc123def456');
}); });
it('should disable database when type is NONE', () => { it('should disable database when type is NONE', () => {
+192 -157
View File
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as k8s from '@kubernetes/client-node'; import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
@@ -17,6 +18,7 @@ import { HelmService } from './helm.service';
import { RegistryService } from './registry.service'; import { RegistryService } from './registry.service';
import { K8sClientService } from './k8s-client.service'; import { K8sClientService } from './k8s-client.service';
import { K8sLifecycleService } from './k8s-lifecycle.service'; import { K8sLifecycleService } from './k8s-lifecycle.service';
import { userNamespace, userIdSlug } from './k8s-workload.util';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -209,8 +211,27 @@ export class KubernetesService implements OnModuleInit {
} }
/** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */ /** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */
/**
* Return the app's database password, generating and PERSISTING one if it is
* missing. Without persistence a fresh password would be generated on every
* helm upgrade, breaking auth against the database's persisted volume.
*/
private ensureDbPassword(app: Application): string {
if (!app.dbPassword) {
app.dbPassword = this.generatePassword();
this.deploymentsRepository.manager
.getRepository(Application)
.update(app.id, { dbPassword: app.dbPassword })
.catch((e: any) =>
this.logger.warn(`Failed to persist generated dbPassword for ${app.name}: ${e.message}`),
);
this.logger.warn(`App ${app.name} had no dbPassword — generated and persisted one`);
}
return app.dbPassword;
}
private buildManagedHelmValues(app: Application): Record<string, any> { private buildManagedHelmValues(app: Application): Record<string, any> {
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const pullRegistryUrl = this.registryService.getRegistryUrl(); 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;
@@ -247,7 +268,10 @@ export class KubernetesService implements OnModuleInit {
type: app.databaseType, type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'), version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser', username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(), password:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
storageSize: app.dbStorageSize || '1Gi', storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app), resources: this.resolveDatabaseResources(app),
}, },
@@ -260,6 +284,7 @@ export class KubernetesService implements OnModuleInit {
ownerId: app.userId, ownerId: app.userId,
applicationId: app.id, applicationId: app.id,
}, },
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`, changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`,
}; };
@@ -287,7 +312,7 @@ export class KubernetesService implements OnModuleInit {
private buildHelmValues(app: Application, imageUri: string, previewNumber?: string | null): Record<string, any> { private buildHelmValues(app: Application, imageUri: string, previewNumber?: string | null): Record<string, any> {
const domain = this.configService.get('platform.domain'); const domain = this.configService.get('platform.domain');
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain; const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0]; const namespacePrefix = userIdSlug(app.userId);
const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : ''; const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
const pullRegistryUrl = this.registryService.getRegistryUrl(); const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS; const isWordPress = app.runtime === AppRuntime.WORDPRESS;
@@ -299,7 +324,7 @@ export class KubernetesService implements OnModuleInit {
app: { app: {
enabled: true, enabled: true,
name: app.name, name: app.name,
namespace: `user-${app.userId.split('-')[0]}`, namespace: this.getUserNamespace(app.userId),
runtime: app.runtime, runtime: app.runtime,
image: imageUri, image: imageUri,
port: app.port, port: app.port,
@@ -330,7 +355,7 @@ export class KubernetesService implements OnModuleInit {
type: app.databaseType, type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'), version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser', username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(), password: hasDb ? this.ensureDbPassword(app) : '',
storageSize: app.dbStorageSize || '1Gi', storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app), resources: this.resolveDatabaseResources(app),
}, },
@@ -344,10 +369,11 @@ export class KubernetesService implements OnModuleInit {
logPaths: app.logPaths || [], logPaths: app.logPaths || [],
ownerId: app.userId, ownerId: app.userId,
applicationId: app.id, applicationId: app.id,
elasticPassword: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure', elasticPassword: this.configService.get<string>('elasticsearch.password'),
fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer', fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword'),
kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System', kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword'),
}, },
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`, changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
}; };
@@ -389,7 +415,7 @@ export class KubernetesService implements OnModuleInit {
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> { async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const managed = isManagedProductType(app.productType); const managed = isManagedProductType(app.productType);
const workloads = [ const workloads = [
...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []), ...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []),
@@ -429,7 +455,7 @@ export class KubernetesService implements OnModuleInit {
async updateIngress(app: Application): Promise<void> { async updateIngress(app: Application): Promise<void> {
const domain = this.configService.get('platform.domain'); const domain = this.configService.get('platform.domain');
const subdomain = app.subdomain || app.name; const subdomain = app.subdomain || app.name;
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const customDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : undefined; const customDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : undefined;
// When there's no verified custom domain, restore the stable preview host so // When there's no verified custom domain, restore the stable preview host so
@@ -528,7 +554,7 @@ export class KubernetesService implements OnModuleInit {
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig); await this.ensurePlatformStorageClass(kubeconfig);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const context: ManifestContext = { const context: ManifestContext = {
appName: app.name, appName: app.name,
namespace, namespace,
@@ -545,7 +571,10 @@ export class KubernetesService implements OnModuleInit {
domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir', domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir',
subdomain: app.subdomain || app.name, subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser', dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(), dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '', dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi', dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
@@ -602,7 +631,7 @@ export class KubernetesService implements OnModuleInit {
const context: ManifestContext = { const context: ManifestContext = {
appName: app.name, appName: app.name,
namespace: `user-${app.userId.split('-')[0]}`, namespace: this.getUserNamespace(app.userId),
image: imageUri, image: imageUri,
port: app.port, port: app.port,
replicas: app.replicas, replicas: app.replicas,
@@ -616,7 +645,10 @@ export class KubernetesService implements OnModuleInit {
domain: domain, domain: domain,
subdomain: app.subdomain || app.name, subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser', dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(), dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '', dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi', dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
@@ -1172,10 +1204,10 @@ export class KubernetesService implements OnModuleInit {
/** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */ /** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */
private async ensureElasticsearchCredentialsSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> { private async ensureElasticsearchCredentialsSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
const name = 'elasticsearch-credentials'; const name = 'elasticsearch-credentials';
const stringData = { const stringData: { [key: string]: string } = {
ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure', ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || '',
FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer', FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || '',
KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System', KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || '',
}; };
try { try {
@@ -1512,7 +1544,7 @@ export class KubernetesService implements OnModuleInit {
} }
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || ctx.domain; const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || ctx.domain;
const namespacePrefix = ctx.ownerId.split('-')[0]; const namespacePrefix = userIdSlug(ctx.ownerId);
const previewHost = previewNumber && !customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : ''; const previewHost = previewNumber && !customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
if (previewHost) { if (previewHost) {
rules.push({ rules.push({
@@ -2228,7 +2260,7 @@ export class KubernetesService implements OnModuleInit {
async scaleDeployment(app: Application, replicas: number): Promise<void> { async scaleDeployment(app: Application, replicas: number): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json')); await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
} }
@@ -2268,7 +2300,7 @@ export class KubernetesService implements OnModuleInit {
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> { async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const snapshot: Record<string, number> = {}; const snapshot: Record<string, number> = {};
for (const workload of this.getApplicationWorkloadDeployments(app)) { for (const workload of this.getApplicationWorkloadDeployments(app)) {
@@ -2297,7 +2329,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async suspendApplication(app: Application): Promise<Record<string, number>> { async suspendApplication(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`); this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
@@ -2323,7 +2355,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async resumeApplication(app: Application): Promise<void> { async resumeApplication(app: Application): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`); this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
@@ -2355,7 +2387,7 @@ export class KubernetesService implements OnModuleInit {
async restartDeployment(app: Application): Promise<void> { async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name; const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
await appsApi.patchNamespacedDeployment( await appsApi.patchNamespacedDeployment(
@@ -2550,7 +2582,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async getResourceUsage(app: Application): Promise<any> { async getResourceUsage(app: Application): Promise<any> {
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const workloads: any[] = []; const workloads: any[] = [];
@@ -2640,7 +2672,7 @@ export class KubernetesService implements OnModuleInit {
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app', workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
): Promise<void> { ): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const target = this.workloadDeploymentTarget(app, workload); const target = this.workloadDeploymentTarget(app, workload);
if (!target) { if (!target) {
@@ -2685,7 +2717,7 @@ export class KubernetesService implements OnModuleInit {
} }
getUserNamespace(userId: string): string { getUserNamespace(userId: string): string {
return `user-${userId.split('-')[0]}`; return userNamespace(userId);
} }
private getClusterHostIp(kc: k8s.KubeConfig): string { private getClusterHostIp(kc: k8s.KubeConfig): string {
@@ -2979,7 +3011,7 @@ export class KubernetesService implements OnModuleInit {
const subdomain = app.subdomain || app.name; const subdomain = app.subdomain || app.name;
const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null; const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null;
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain; const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0]; const namespacePrefix = userIdSlug(app.userId);
let ingressUrl = `https://${subdomain}.${domain}`; let ingressUrl = `https://${subdomain}.${domain}`;
if (verifiedCustomDomain) { if (verifiedCustomDomain) {
@@ -3362,7 +3394,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> { async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const dbLabel = `${app.name}-db`; const dbLabel = `${app.name}-db`;
const start = Date.now(); const start = Date.now();
@@ -3469,14 +3501,12 @@ export class KubernetesService implements OnModuleInit {
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> { async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api); const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const dbName = `${app.name}-db`; const dbName = `${app.name}-db`;
const ts = Date.now(); const ts = Date.now();
const pvcName = `${app.name}-db-dump-${ts}`; const pvcName = `${app.name}-db-dump-${ts}`;
const helperPodName = `${pvcName}-helper`; const helperPodName = `${pvcName}-helper`;
const jobName = `${app.name}-db-restore-${ts}`; const jobName = `${app.name}-db-restore-${ts}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const dumpSize = fs.statSync(dumpFilePath).size; const dumpSize = fs.statSync(dumpFilePath).size;
const pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024))); const pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024)));
@@ -3562,14 +3592,8 @@ export class KubernetesService implements OnModuleInit {
} catch {} } catch {}
} }
// ── 4. Build restore command ── // ── 4. Build restore command (per database type) ──
const command = isPostgres const { image, restoreCommand: command } = this.databaseDumpSpec(app, dbName);
? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" psql -h ${dbName} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`]
: ['sh', '-c', `mysql -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`];
const defaultDbVer = isPostgres ? '16' : '8.0';
const restoreDbVer = app.dbVersion || defaultDbVer;
const image = isPostgres ? `postgres:${restoreDbVer}-alpine` : `mysql:${restoreDbVer}`;
// ── 5. Create the restore Job ── // ── 5. Create the restore Job ──
const job: k8s.V1Job = { const job: k8s.V1Job = {
@@ -3777,7 +3801,7 @@ export class KubernetesService implements OnModuleInit {
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> { private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api); const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const oldPvcName = `${app.name}-db`; const oldPvcName = `${app.name}-db`;
const newPvcName = `${app.name}-db-resizable`; const newPvcName = `${app.name}-db-resizable`;
const deploymentName = `${app.name}-db`; const deploymentName = `${app.name}-db`;
@@ -3952,7 +3976,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-db`; const pvcName = `${app.name}-db`;
try { try {
@@ -4025,7 +4049,7 @@ export class KubernetesService implements OnModuleInit {
async getDatabasePvcSize(app: Application): Promise<string> { async getDatabasePvcSize(app: Application): Promise<string> {
try { try {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-db`; const pvcName = `${app.name}-db`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({ const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
@@ -4051,7 +4075,7 @@ export class KubernetesService implements OnModuleInit {
totalUsedGb: number; totalUsedGb: number;
}> { }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const result = { const result = {
database: null as StorageUsageSlice | null, database: null as StorageUsageSlice | null,
@@ -4260,7 +4284,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> { async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
try { try {
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({ const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
@@ -4317,7 +4341,7 @@ export class KubernetesService implements OnModuleInit {
*/ */
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
// Try new unified name first, then legacy wp-content name // Try new unified name first, then legacy wp-content name
let pvcName = `${app.name}-storage`; let pvcName = `${app.name}-storage`;
@@ -4374,6 +4398,60 @@ export class KubernetesService implements OnModuleInit {
// ─── Snapshot helpers ─────────────────────────────── // ─── Snapshot helpers ───────────────────────────────
/**
* Per-database tooling for dump/restore jobs. `dumpCommand` writes to
* `outputPath`; `restoreCommand` reads from `/dump/dump.sql` (the copied
* dump file keeps that name regardless of format mongodump archives are
* binary but mongorestore does not care about the extension).
*/
private databaseDumpSpec(app: Application, dbHost: string): {
image: string;
outputPath: string;
dumpCommand: string[];
restoreCommand: string[];
} {
const dbDatabase = app.name.replace(/-/g, '_');
switch (app.databaseType) {
case DatabaseType.POSTGRESQL: {
const image = `postgres:${app.dbVersion || '16'}-alpine`;
return {
image,
outputPath: '/dump/output.sql',
dumpCommand: ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbHost} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" psql -h ${dbHost} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`],
};
}
case DatabaseType.MONGODB: {
const image = `mongo:${app.dbVersion || '7.0'}`;
const auth = `-u "$DB_USER" -p "$DB_PASSWORD" --authenticationDatabase admin`;
return {
image,
outputPath: '/dump/output.archive',
dumpCommand: ['sh', '-c', `mongodump --host ${dbHost} ${auth} --db ${dbDatabase} --archive=/dump/output.archive --gzip 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `mongorestore --host ${dbHost} ${auth} --nsInclude '${dbDatabase}.*' --archive=/dump/dump.sql --gzip --drop 2>&1`],
};
}
case DatabaseType.MARIADB: {
const image = `mariadb:${app.dbVersion || '11'}`;
return {
image,
outputPath: '/dump/output.sql',
dumpCommand: ['sh', '-c', `mariadb-dump -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `mariadb -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`],
};
}
default: {
const image = `mysql:${app.dbVersion || '8.0'}`;
return {
image,
outputPath: '/dump/output.sql',
dumpCommand: ['sh', '-c', `mysqldump -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `mysql -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`],
};
}
}
}
/** /**
* Export (dump) the application database to a local file via a K8s Job. * Export (dump) the application database to a local file via a K8s Job.
* Returns the dump as a Buffer, or null on failure. * Returns the dump as a Buffer, or null on failure.
@@ -4383,20 +4461,12 @@ export class KubernetesService implements OnModuleInit {
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> { async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api); const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const dbName = `${app.name}-db`; const dbName = `${app.name}-db`;
const jobName = `${app.name}-db-dump-${Date.now()}`; const jobName = `${app.name}-db-dump-${Date.now()}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const defaultDbVer = isPostgres ? '16' : '8.0'; // Dump command writes to spec.outputPath, then sleeps to allow exec retrieval
const dbVer = app.dbVersion || defaultDbVer; const { image, outputPath, dumpCommand: command } = this.databaseDumpSpec(app, dbName);
const image = isPostgres ? `postgres:${dbVer}-alpine` : `mysql:${dbVer}`;
// Dump command writes to /dump/output.sql, then sleeps to allow exec retrieval
const command = isPostgres
? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbName} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`]
: ['sh', '-c', `mysqldump -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`];
const job: k8s.V1Job = { const job: k8s.V1Job = {
apiVersion: 'batch/v1', apiVersion: 'batch/v1',
@@ -4519,7 +4589,7 @@ export class KubernetesService implements OnModuleInit {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
exec.exec(namespace, podName!, 'dump', ['cat', '/dump/output.sql'], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => { exec.exec(namespace, podName!, 'dump', ['cat', outputPath], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => {
if (status.status === 'Success') resolve(); if (status.status === 'Success') resolve();
else reject(new Error(status.message || 'exec failed')); else reject(new Error(status.message || 'exec failed'));
}); });
@@ -4564,7 +4634,7 @@ export class KubernetesService implements OnModuleInit {
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> { async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api); const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-storage`; const pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-archive-${Date.now()}`; const jobName = `${app.name}-wp-archive-${Date.now()}`;
@@ -4712,127 +4782,92 @@ export class KubernetesService implements OnModuleInit {
/** /**
* Restore wp-content from a tar.gz archive into the WordPress PVC. * Restore wp-content from a tar.gz archive into the WordPress PVC.
*
* The archive is streamed into a helper pod with `kubectl cp` (a Secret
* would be capped at ~1MiB far too small for real wp-content) and
* extracted in place onto the mounted PVC.
*/ */
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> { async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = this.getUserNamespace(app.userId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-storage`; const pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-restore-${Date.now()}`; const ts = Date.now();
const secretName = `${jobName}-archive`; const helperPodName = `${app.name}-wp-restore-${ts}`;
// Store archive in a secret const helperPod: k8s.V1Pod = {
const archiveSecret = {
apiVersion: 'v1', apiVersion: 'v1',
kind: 'Secret', kind: 'Pod',
metadata: { name: secretName, namespace }, metadata: { name: helperPodName, namespace },
data: { 'wp-content.tar.gz': archiveBuffer.toString('base64') },
};
try {
await coreApi.createNamespacedSecret({ namespace, body: archiveSecret });
} catch (e: any) {
return {
success: false,
logs: `Failed to create archive secret: ${e.message}`,
};
}
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: { spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [ containers: [
{ {
name: 'restore', name: 'restore',
image: 'alpine:3.19', image: 'alpine:3.19',
command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && rm -rf /wp-content/* && cd /wp-content && tar xzf /archive/wp-content.tar.gz && echo "RESTORE_DONE"'], command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [ volumeMounts: [{ name: 'wp-content', mountPath: '/wp-content' }],
{ name: 'wp-content', mountPath: '/wp-content' },
{ name: 'archive', mountPath: '/archive', readOnly: true },
],
resources: { resources: {
requests: { cpu: '100m', memory: '64Mi' }, requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '256Mi' }, limits: { cpu: '500m', memory: '512Mi' },
}, },
}, },
], ],
volumes: [ volumes: [{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } }],
{ restartPolicy: 'Never',
name: 'wp-content',
persistentVolumeClaim: { claimName: pvcName },
},
{ name: 'archive', secret: { secretName } },
],
},
},
}, },
}; };
const tmpArchive = path.join(os.tmpdir(), `wp-content-restore-${ts}.tar.gz`);
const tmpKubeconfig = path.join(os.tmpdir(), `kubeconfig-wprestore-${ts}.yaml`);
try { try {
await batchApi.createNamespacedJob({ namespace, body: job }); fs.writeFileSync(tmpArchive, archiveBuffer);
fs.writeFileSync(tmpKubeconfig, kc.exportConfig());
await coreApi.createNamespacedPod({ namespace, body: helperPod });
// Wait for helper pod Running
const podTimeout = 120_000;
const podStart = Date.now();
while (Date.now() - podStart < podTimeout) {
const pod = await coreApi.readNamespacedPod({ name: helperPodName, namespace });
if (pod.status?.phase === 'Running') break;
if (pod.status?.phase === 'Failed') throw new Error('wp-content restore helper pod failed to start');
await new Promise((r) => setTimeout(r, 2000));
}
await execFileAsync(
'kubectl',
['--kubeconfig', tmpKubeconfig, 'cp', tmpArchive, `${namespace}/${helperPodName}:/tmp/wp-content.tar.gz`, '--retries', '3'],
{ maxBuffer: 50 * 1024 * 1024, timeout: 600_000 },
);
const { stdout, stderr } = await execFileAsync(
'kubectl',
[
'--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '--',
'sh', '-c',
'rm -rf /wp-content/* /wp-content/.[!.]* 2>/dev/null; tar xzf /tmp/wp-content.tar.gz -C /wp-content && echo RESTORE_DONE',
],
{ maxBuffer: 10 * 1024 * 1024, timeout: 600_000 },
);
const logs = `${stdout || ''}${stderr || ''}`;
const success = logs.includes('RESTORE_DONE');
return { success, logs: logs || (success ? 'Restore completed' : 'Restore failed') };
} catch (e: any) { } catch (e: any) {
this.logger.error(`wp-content restore failed for ${app.name}: ${e.message}`);
return { success: false, logs: e.message || 'wp-content restore failed' };
} finally {
try { try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace }); fs.unlinkSync(tmpArchive);
} catch {} } catch {}
return {
success: false,
logs: `Failed to create restore job: ${e.message}`,
};
}
// Wait
const timeout = 300_000;
const start = Date.now();
let succeeded = false;
let failed = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try { try {
const st = await batchApi.readNamespacedJob({ fs.unlinkSync(tmpKubeconfig);
name: jobName, } catch {}
namespace, try {
}); await coreApi.deleteNamespacedPod({ name: helperPodName, namespace });
if (st.status?.succeeded && st.status.succeeded > 0) {
succeeded = true;
break;
}
if (st.status?.failed && st.status.failed > 0) {
failed = true;
break;
}
} catch {} } catch {}
} }
let logs = '';
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
if (pods.items.length > 0 && pods.items[0].metadata?.name) {
const logRes = await coreApi.readNamespacedPodLog({
name: pods.items[0].metadata.name,
namespace,
});
logs = logRes || '';
}
} catch {}
try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace });
} catch {}
return {
success: succeeded && !failed,
logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out'),
};
} }
// ─── K8s Revision-based Rollback ───────────────────── // ─── K8s Revision-based Rollback ─────────────────────
@@ -4852,7 +4887,7 @@ export class KubernetesService implements OnModuleInit {
}>; }>;
currentRevision: number; currentRevision: number;
}> { }> {
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name; const releaseName = app.name;
try { try {
@@ -4890,7 +4925,7 @@ export class KubernetesService implements OnModuleInit {
* Rollback a Helm release to a specific revision. * Rollback a Helm release to a specific revision.
*/ */
async rollbackDeploymentRevision(app: Application, targetRevision: number): Promise<{ success: boolean; message: string }> { async rollbackDeploymentRevision(app: Application, targetRevision: number): Promise<{ success: boolean; message: string }> {
const namespace = `user-${app.userId.split('-')[0]}`; const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name; const releaseName = app.name;
try { try {
+2 -1
View File
@@ -18,6 +18,7 @@ import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ElasticsearchService } from './elasticsearch.service'; import { ElasticsearchService } from './elasticsearch.service';
import { userNamespace } from './k8s-workload.util';
import { Application } from '../applications/entities/application.entity'; import { Application } from '../applications/entities/application.entity';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
@@ -225,7 +226,7 @@ export class LogsController {
if (appFilters.applicationName) { if (appFilters.applicationName) {
filterParts.push(`applicationName:${appFilters.applicationName}`); filterParts.push(`applicationName:${appFilters.applicationName}`);
} }
filterParts.push(`namespace:user-${userId.split('-')[0]}`); filterParts.push(`namespace:${userNamespace(userId)}`);
const kibanaHost = connInfo.host.replace('elasticsearch', 'kibana'); const kibanaHost = connInfo.host.replace('elasticsearch', 'kibana');
const query = filterParts.length > 0 ? filterParts.join(' AND ') : '*'; const query = filterParts.length > 0 ? filterParts.join(' AND ') : '*';
+12 -3
View File
@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory, Reflector } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common'; import { ClassSerializerInterceptor, Logger, ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@@ -47,10 +47,16 @@ async function bootstrap() {
}), }),
); );
// Strip @Exclude()-marked fields (e.g. gitToken) from serialized responses.
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
// API prefix // API prefix
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
// Swagger // Swagger — disabled in production unless explicitly opted in (SWAGGER_ENABLED=true)
const swaggerEnabled =
process.env.NODE_ENV !== 'production' || process.env.SWAGGER_ENABLED === 'true';
if (swaggerEnabled) {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('CloudHost PaaS API') .setTitle('CloudHost PaaS API')
.setDescription('Self-service PaaS platform API') .setDescription('Self-service PaaS platform API')
@@ -59,10 +65,13 @@ async function bootstrap() {
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.PORT || 4000; const port = process.env.PORT || 4000;
await app.listen(port); await app.listen(port);
console.log(`🚀 CloudHost API running on http://localhost:${port}`); console.log(`🚀 CloudHost API running on http://localhost:${port}`);
if (swaggerEnabled) {
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`); console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
} }
}
bootstrap(); bootstrap();
@@ -6,6 +6,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from 'typeorm';
import { Exclude } from 'class-transformer';
import { UserRole } from '../../common/enums'; import { UserRole } from '../../common/enums';
import { Application } from '../../applications/entities/application.entity'; import { Application } from '../../applications/entities/application.entity';
@@ -30,6 +31,8 @@ export class User {
@Column({ default: false }) @Column({ default: false })
phoneVerified: boolean; phoneVerified: boolean;
/** Bcrypt hash — never serialized into API responses. */
@Exclude({ toPlainOnly: true })
@Column() @Column()
password: string; password: string;
+4 -2
View File
@@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
import { UserRole } from '../common/enums'; import { UserRole } from '../common/enums';
import { normalizeIranMobile } from '../common/phone.util'; import { normalizeIranMobile } from '../common/phone.util';
import { userNamespace } from '../kubernetes/k8s-workload.util';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -22,9 +23,10 @@ export class UsersService {
async create(data: Partial<User>): Promise<User> { async create(data: Partial<User>): Promise<User> {
const user = this.usersRepository.create(data); const user = this.usersRepository.create(data);
// Assign a unique namespace based on user ID // Assign a unique namespace based on the FULL user UUID (truncated
// prefixes collide between users and break tenant isolation).
const saved = await this.usersRepository.save(user); const saved = await this.usersRepository.save(user);
saved.namespace = `user-${saved.id.split('-')[0]}`; saved.namespace = userNamespace(saved.id);
return this.usersRepository.save(saved); return this.usersRepository.save(saved);
} }
@@ -1566,7 +1566,7 @@ export default function AppDetailPage() {
<GitBranch className="w-3 h-3" /> {app.gitBranch} <GitBranch className="w-3 h-3" /> {app.gitBranch}
</span> </span>
)} )}
{app.gitToken && ( {(app.hasGitToken ?? app.gitToken) && (
<span className="text-xs text-green-600 flex items-center gap-1"> <span className="text-xs text-green-600 flex items-center gap-1">
<KeyRound className="w-3 h-3" />{ad.private}</span> <KeyRound className="w-3 h-3" />{ad.private}</span>
)} )}
+2
View File
@@ -33,6 +33,8 @@ export interface Application {
appStorageSize?: string; appStorageSize?: string;
gitUrl?: string; gitUrl?: string;
gitToken?: string; gitToken?: string;
/** Server-provided indicator; raw gitToken is no longer returned by the API. */
hasGitToken?: boolean;
gitBranch?: string; gitBranch?: string;
codePath?: string; codePath?: string;
envVars?: Record<string, string>; envVars?: Record<string, string>;
+2
View File
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+317
View File
@@ -0,0 +1,317 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<title>گزارش بررسی فنی CloudHost</title>
<style>
@page { size: A4; }
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
font-family: "Vazirmatn", "IRANSans", "Tahoma", "Segoe UI", sans-serif;
color: #1f2933;
line-height: 1.85;
font-size: 12px;
margin: 0;
}
h1, h2, h3 { line-height: 1.5; }
h1 { font-size: 26px; margin: 0 0 4px; color: #0b3d2e; }
.subtitle { color: #52606d; font-size: 13px; margin: 0; }
.meta { color: #7b8794; font-size: 11px; margin-top: 8px; }
h2 {
font-size: 17px; color: #0b3d2e; margin: 26px 0 10px;
border-bottom: 2px solid #d9e2ec; padding-bottom: 6px;
}
h3 { font-size: 14px; color: #243b53; margin: 18px 0 6px; }
.cover {
background: linear-gradient(135deg, #0b3d2e, #1f6f54);
color: #fff; padding: 42px 34px; border-radius: 14px; margin-bottom: 8px;
}
.cover h1 { color: #fff; }
.cover .subtitle { color: #cfe9df; }
.cover .meta { color: #a7d3c4; }
code {
background: #f0f4f8; color: #b91c1c; padding: 1px 5px;
border-radius: 4px; font-family: "SFMono-Regular", Consolas, monospace;
font-size: 10.5px; direction: ltr; unicode-bidi: embed; display: inline-block;
}
.path { color: #334e68; font-family: "SFMono-Regular", Consolas, monospace; font-size: 10.5px; direction: ltr; unicode-bidi: embed; }
ul, ol { margin: 6px 0; padding-inline-start: 22px; }
li { margin: 5px 0; }
.finding { margin: 10px 0; padding: 10px 12px; border-radius: 8px; border: 1px solid #e4e7eb; background: #fafbfc; }
.finding .head { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.badge {
display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 999px; color: #fff; white-space: nowrap;
}
.bug { background: #b91c1c; }
.sec { background: #7c2d12; }
.risk { background: #b45309; }
.imp { background: #1d4ed8; }
.finding .title { font-weight: 700; color: #102a43; }
.finding .desc { margin: 2px 0 0; }
.tag-legend { display: flex; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 11px; }
th, td { border: 1px solid #d9e2ec; padding: 6px 8px; text-align: right; vertical-align: top; }
th { background: #f0f4f8; color: #243b53; }
.prio-num { font-weight: 700; color: #0b3d2e; }
.section { page-break-inside: avoid; }
.pagebreak { page-break-before: always; }
.summary-box { background: #fff7ed; border: 1px solid #fed7aa; border-radius: 10px; padding: 14px 18px; margin: 14px 0; }
.summary-box ol { padding-inline-start: 20px; }
footer { margin-top: 30px; color: #9aa5b1; font-size: 10px; text-align: center; border-top: 1px solid #e4e7eb; padding-top: 8px; }
</style>
</head>
<body>
<div class="cover">
<h1>گزارش بررسی فنی پلتفرم CloudHost</h1>
<p class="subtitle">باگ‌ها، ریسک‌های پروداکشن و موارد بهبود — بیلد، دیتابیس‌ها، GitOps/CI-CD و امنیت اپلیکیشن</p>
<p class="meta">تاریخ: ۲ تیر ۱۴۰۴ (2 Jul 2026) · محدوده: کل مخزن cloud-host</p>
</div>
<div class="tag-legend">
<span class="badge bug">BUG — قطعاً می‌شکند</span>
<span class="badge sec">SECURITY — حفره امنیتی</span>
<span class="badge risk">RISK — احتمال شکست در پروداکشن</span>
<span class="badge imp">IMPROVEMENT — بهبود</span>
</div>
<div class="section">
<h2>خلاصه مدیریتی</h2>
<p>پروژه معماری خوبی دارد اما در وضعیت فعلی <strong>آماده پروداکشن نیست</strong>. چند دسته مشکل بحرانی وجود دارد که یا هم‌اکنون باگ هستند یا حتماً در پروداکشن (به‌ویژه در شبکه ایران) می‌شکنند:</p>
<ol>
<li><strong>باگ‌های قطعی بیلد</strong> — برخی Dockerfileها اصلاً build نمی‌شوند (مثلاً Go).</li>
<li><strong>باگ چرخه دوم آپگرید</strong> — سیستم migration در دومین <code>helm upgrade</code> قطعاً می‌شکند.</li>
<li><strong>حفره‌های امنیتی مالی</strong> — کاربر می‌تواند کیف پول خود را رایگان شارژ کند و بدون پرداخت دیپلوی کند.</li>
<li><strong>وابستگی به Docker Hub</strong> بدون آینه (mirror) برای ایمیج دیتابیس‌ها و base imageها.</li>
<li><strong>چرخش رمز سرویس‌ها</strong> — رمز Redis/RabbitMQ در هر آپگرید عوض می‌شود و اتصال اپ قطع می‌شود.</li>
</ol>
</div>
<div class="section pagebreak">
<h2>۱. فرایند بیلد اپلیکیشن‌ها (Kaniko + Dockerfile هر رانتایم)</h2>
<h3>باگ‌های قطعی</h3>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">Go — سینتکس نامعتبر COPY؛ هر بیلد Go خراب می‌شود</span></div>
<p class="desc"><span class="path">backend/src/build/build.service.ts:1311-1314</span> — دستور <code>COPY ... 2&gt;/dev/null || true</code> از ریدایرکت شل پشتیبانی نمی‌کند؛ Kaniko این خطوط را رد می‌کند و بیلد هر اپ Go شکست می‌خورد.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">Node.js — شکست بیلد نادیده گرفته می‌شود</span></div>
<p class="desc"><span class="path">backend/src/build/build.service.ts:1034</span><code>RUN npm run build || echo "..."</code>؛ اگر بیلد خطا بدهد باز هم ایمیج ساخته می‌شود و اپ خراب دیپلوی می‌شود. کاربر «بیلد موفق» می‌بیند ولی اپ کار نمی‌کند.</p>
</div>
<h3>ریسک‌های جدی</h3>
<div class="finding">
<div class="head"><span class="badge risk">RISK</span><span class="title">Base imageها بدون آینه، از Docker Hub / GCR / MCR</span></div>
<p class="desc">همه رانتایم‌ها (<code>node:</code>, <code>php:</code>, <code>python:</code>, <code>golang:</code>, <code>wordpress:</code>) و ایمیج Kaniko و init pods (<code>alpine:3.19</code>, <code>alpine/git</code>) مستقیم از رجیستری‌های عمومی pull می‌شوند. آینه فقط برای استک لاگینگ تعریف شده (<span class="path">configuration.ts:151</span>). در ایران بیشترین منبع شکست بیلد است.</p>
</div>
<div class="finding">
<div class="head"><span class="badge risk">RISK</span><span class="title">Laravel — نبود اکستنشن‌های ضروری PHP</span></div>
<p class="desc"><span class="path">backend/src/build/build.service.ts:1085</span> — فقط <code>pdo, pdo_mysql, opcache</code> نصب می‌شود؛ <code>mbstring, xml, bcmath, zip, fileinfo, tokenizer</code> که Laravel استاندارد لازم دارد نصب نمی‌شود.</p>
</div>
<div class="finding">
<div class="head"><span class="badge risk">RISK</span><span class="title">Python — پروژه‌های pyproject.toml پشتیبانی نمی‌شوند</span></div>
<p class="desc"><span class="path">backend/src/build/build.service.ts:1413</span> — تشخیص‌دهنده <code>pyproject.toml</code> را Python می‌شناسد ولی Dockerfile فقط <code>requirements.txt</code> نصب می‌کند؛ پروژه‌های Poetry/PDM فقط Flask+gunicorn پیش‌فرض می‌گیرند. اگر install خطا بدهد، fallback خاموش (<code>2&gt;/dev/null ||</code>) اپ اشتباه بالا می‌آورد.</p>
</div>
<div class="finding">
<div class="head"><span class="badge risk">RISK</span><span class="title">حافظه Kaniko فقط ۴Gi و PVC بیلد بدون StorageClass</span></div>
<p class="desc"><span class="path">build.service.ts:588</span> بیلد Next.js/.NET/Composer اغلب بیشتر می‌خواهد → OOMKilled. <span class="path">build.service.ts:775</span> PVC بیلد <code>storageClassName</code> ندارد → در کلاستر بدون SC پیش‌فرض برای همیشه Pending می‌ماند. همچنین <code>npm install --legacy-peer-deps</code> به‌جای <code>npm ci</code> (خط ۱۰۱۸).</p>
</div>
<h3>امنیت بیلد</h3>
<div class="finding">
<div class="head"><span class="badge sec">SECURITY</span><span class="title">توکن Git داخل spec پاد و تزریق دستور از branch</span></div>
<p class="desc"><span class="path">build.service.ts:498-523</span><code>cloneUrl</code> با توکن embed‌شده در command کانتینر → قابل دیدن در <code>kubectl get pod -o yaml</code>، etcd و audit log. همچنین <code>${branch}</code> بدون کوت داخل شل → نامی مثل <code>main; curl evil</code> کد اجرا می‌کند. بدون اعتبارسنجی URL گیت (SSRF به IPهای داخلی کلاستر). خطر Zip slip / zip bomb در استخراج با <code>unzip</code> (خط ۴۶۲) با سقف آپلود ۱۰GiB.</p>
</div>
<h3>پایداری فرایند</h3>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">ری‌استارت backend وسط بیلد → deployment گیر می‌کند</span></div>
<p class="desc"><span class="path">build.service.ts:56</span> — state بیلد در <code>Map</code> حافظه است؛ بعد از ری‌استارت، Job روی کلاستر ادامه می‌دهد ولی deployment در وضعیت <code>BUILDING</code> گیر می‌کند و reconcile نمی‌شود. همچنین دیپلوی هم‌زمان برای یک اپ قفل ندارد و روی همان Helm release رقابت می‌کنند.</p>
</div>
</div>
<div class="section pagebreak">
<h2>۲. پیش‌نمایش و دیپلوی</h2>
<p>پیش‌نمایش با ساخت یک عدد ۷ رقمی پایدار برای هر اپ و host به‌شکل <code>{userPrefix}-{previewNumber}.{previewRootDomain}</code> کار می‌کند.</p>
<div class="finding">
<div class="head"><span class="badge risk">RISK</span><span class="title">با ست‌شدن دامنه اختصاصی، پیش‌نمایش بلافاصله حذف می‌شود</span></div>
<p class="desc"><span class="path">kubernetes.service.ts:291</span> — حتی قبل از تأیید DNS؛ کاربر تا وریفای شدن دامنه هیچ آدرس قابل‌دسترسی ندارد. پیش‌نمایش نیازمند DNS wildcard فعال + cert-manager و مقدار <code>PREVIEW_BASE_DOMAIN</code> است.</p>
</div>
<div class="finding">
<div class="head"><span class="badge risk">RISK</span><span class="title">getPreviewInfo روی هر فراخوانی Service را به NodePort پچ می‌کند</span></div>
<p class="desc"><span class="path">kubernetes.service.ts:2955</span> — عارضه جانبی که ممکن است اپ را ناخواسته روی IP نود باز کند.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">رجیستری per-cluster + fallback بین‌کلاستری → ImagePullBackOff</span></div>
<p class="desc"><span class="path">deployments.service.ts:360</span> — ایمیج روی رجیستری کلاستر A ساخته و push می‌شود، ولی <code>deployWithClusterFallback</code> می‌تواند روی کلاستر B دیپلوی کند که آن ایمیج را ندارد.</p>
</div>
</div>
<div class="section pagebreak">
<h2>۳. دیتابیس‌ها و سرویس‌های اختیاری</h2>
<h3>باگ‌ها</h3>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">رمز Redis و RabbitMQ در هر helm upgrade عوض می‌شود</span></div>
<p class="desc"><span class="path">redis-deployment.yaml:18</span>، <span class="path">rabbitmq-deployment.yaml:19</span><code>randAlphaNum 16</code> بدون <code>lookup</code> هر بار مقدار جدید تولید می‌کند؛ <code>resource-policy: keep</code> فقط جلوی حذف را می‌گیرد نه تغییر. بعد از هر redeploy رمز عوض می‌شود ولی داده PVC رمز قدیمی دارد → قطع اتصال. الگوی درست در چارت پلتفرم (<span class="path">cloudhost-platform/templates/secret.yaml</span>) با <code>lookup</code> موجود است.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">Health probe رِدیس/مونگو بدون احراز هویت</span></div>
<p class="desc"><span class="path">redis-deployment.yaml:80</span><code>redis-cli ping</code> بدون <code>-a</code>؛ با <code>--requirepass</code> جواب NOAUTH → probe رد → CrashLoopBackOff. همین برای probe مونگو بدون credential.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">MongoDB در snapshot و wp-content restore پشتیبانی نمی‌شوند</span></div>
<p class="desc"><span class="path">kubernetes.service.ts:4389</span> — export/restore فقط Postgres و MySQL دارد؛ اپ Mongo dump خراب می‌گیرد. <span class="path">kubernetes.service.ts:4724</span> — restore محتوای wp-content از طریق Secret ذخیره می‌شود که محدودیت ~۱MiB دارد؛ هر wp-content واقعی بزرگ‌تر است → شکست.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">WordPress + PostgreSQL و WordPress بدون دیتابیس مجاز است</span></div>
<p class="desc">ایمیج رسمی وردپرس فقط MySQL/MariaDB را می‌شناسد ولی پلتفرم <code>databaseType: postgresql</code> یا حتی <code>none</code> را می‌پذیرد → سایت بالا نمی‌آید. باید هنگام رانتایم WordPress دیتابیس اجباراً MySQL شود.</p>
</div>
<h3>ریسک‌ها</h3>
<ul>
<li><span class="badge risk">RISK</span> <strong>ایمیج همه سرویس‌ها از Docker Hub</strong> بدون مکانیزم آینه در چارت اپ (<code>postgres:16-alpine</code>, <code>mysql:8.0</code>, ...)؛ override <code>database.image</code> هست ولی backend هرگز آن را ست نمی‌کند.</li>
<li><span class="badge risk">RISK</span> <strong>Deployment + PVC نوع RWO بدون <code>strategy: Recreate</code></strong> برای دیتابیس/Redis/RabbitMQ → در آپگرید ایمیج پاد جدید منتظر ولوم می‌ماند.</li>
<li><span class="badge risk">RISK</span> <strong>fallback تولید رمز DB</strong> (<span class="path">kubernetes.service.ts:333</span>): اگر <code>dbPassword</code> خالی باشد هر دیپلوی رمز جدید می‌سازد و با داده قدیمی PVC ناسازگار می‌شود.</li>
<li><span class="badge risk">RISK</span> <strong>خاموش‌کردن سرویس PVC یتیم جا می‌گذارد</strong> — کاربر آن‌ها را نمی‌بیند ولی هزینه استوریج ادامه دارد.</li>
<li><span class="badge risk">RISK</span> <strong>دسترسی خارجی NodePort — host اشتباه</strong> (<span class="path">kubernetes.service.ts:2691</span>): IP از API server گرفته می‌شود نه worker node؛ رشته اتصال بلااستفاده است. <code>suspend</code> هم گرنت‌های NodePort را باطل نمی‌کند.</li>
</ul>
</div>
<div class="section pagebreak">
<h2>۴. کنترل‌پلین، GitOps و CI/CD</h2>
<h3>باگ‌ها (باید قبل از دیپلوی بعدی رفع شوند)</h3>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">سیستم migration در آپگرید دوم می‌شکند</span></div>
<p class="desc"><span class="path">migrations-job.yaml:50-53</span> — Job همه فایل‌های SQL را در هر اجرا دوباره اجرا می‌کند بدون جدول ردیابی نسخه. <span class="path">001_service_access_grants.sql:2,9</span> از <code>CREATE TYPE</code> بدون گارد استفاده می‌کند → آپگرید دوم: <code>ERROR: type already exists</code> → sync fail.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">migration هوک بعد از دیپلوی backend اجرا می‌شود</span></div>
<p class="desc"><span class="path">migrations-job.yaml:10-11</span><code>post-upgrade</code>؛ backend جدید ممکن است قبل از آماده شدن اسکیما بالا بیاید → CrashLoop. باید <code>pre-upgrade</code> باشد.</p>
</div>
<div class="finding">
<div class="head"><span class="badge bug">BUG</span><span class="title">نبود base schema و نام ستون اشتباه در migration 015</span></div>
<p class="desc">هیچ SQL جدول‌های <code>users</code>/<code>applications</code> را نمی‌سازد؛ روی دیتابیس خالی اولین migration شکست می‌خورد. <span class="path">015_application_product_type.sql:5-6</span> ستون <code>user_id</code> می‌سازد ولی entity آن را <code>userId</code> تعریف کرده (<span class="path">application.entity.ts:150</span>) → ساخت ایندکس fail.</p>
</div>
<h3>رمزهای هاردکد شده در گیت</h3>
<div class="finding">
<div class="head"><span class="badge sec">SECURITY</span><span class="title">رمزهای الستیک‌سرچ در فایل commit‌شده</span></div>
<p class="desc"><span class="path">backend/k8s/logging/elasticsearch-stack.yaml:21-23</span><code>ELASTIC_PASSWORD: "CloudHost2024!Secure"</code> و <code>FLUENTBIT_PASSWORD</code>. باید rotate و از گیت خارج شوند. همین‌ها به‌عنوان default در <span class="path">configuration.ts:148-150</span> هستند و در validate-production بررسی نمی‌شوند.</p>
</div>
<h3>ریسک‌های CI/CD و کنترل‌پلین</h3>
<ul>
<li><span class="badge risk">RISK</span> workflow کامیت‌شده <strong>auth کانیکو به Harbor</strong> و <strong>توکن clone</strong> ندارد (<span class="path">.gitea/workflows/build-deploy.yaml:74</span>) → push/clone شکست می‌خورد؛ اصلاحات در تغییرات uncommit هستند.</li>
<li><span class="badge risk">RISK</span> <strong>تست‌ها در مسیر Gitea اجرا نمی‌شوند</strong> (فقط GitHub Actions) → کد خراب می‌تواند به پروداکشن برسد.</li>
<li><span class="badge risk">RISK</span> ایمیج backend حین بیلد <strong>Helm و kubectl را از اینترنت دانلود می‌کند</strong> (<span class="path">backend/Dockerfile:16-20</span>) بدون پروکسی.</li>
<li><span class="badge risk">RISK</span> <code>git push</code> بدون <code>pull --rebase</code> (workflow:177) → احتمال half-done deploy.</li>
<li><span class="badge risk">RISK</span> postgres/redis پلتفرم در <span class="path">values-abrban.yaml</span> آینه نشده و imagePullSecret ندارند.</li>
<li><span class="badge risk">RISK</span> <code>strategy: Recreate</code> روی backend (<span class="path">backend-deployment.yaml:12</span>) → داون‌تایم کامل API در هر دیپلوی.</li>
<li><span class="badge risk">RISK</span> بدون resource limits در values پروداکشن → ریسک OOM روی k3s تک‌نود؛ Redis پلتفرم بدون <code>requirepass</code>؛ backup پستگرس خاموش.</li>
<li><span class="badge risk">RISK</span> <code>docker compose up --build</code> کامل کار نمی‌کند — backend با <code>NODE_ENV=production</code><code>synchronize:false</code> و بدون migration → جدول‌ها موجود نیست.</li>
</ul>
</div>
<div class="section pagebreak">
<h2>۵. امنیت و کیفیت کد اپلیکیشن</h2>
<h3>حفره‌های امنیتی بحرانی (P0)</h3>
<div class="finding">
<div class="head"><span class="badge sec">SECURITY</span><span class="title">هر کاربر لاگین‌شده می‌تواند کیف پول خود را رایگان شارژ کند</span></div>
<p class="desc"><span class="path">billing-wallet.controller.ts:45-49</span><code>POST /billing/wallet/charge</code> بدون درگاه پرداخت مستقیم <code>chargeWallet</code> را صدا می‌زند → پول رایگان در پروداکشن. همچنین <code>gateway/verify</code> با <code>PAYMENT_GATEWAY_STUB_ENABLED=true</code> مبلغ دلخواه را می‌پذیرد.</p>
</div>
<div class="finding">
<div class="head"><span class="badge sec">SECURITY</span><span class="title">دور زدن بیلینگ در deploy / start / resources</span></div>
<p class="desc"><span class="path">deployments.service.ts:637</span> <code>startDeployment</code> اپ suspend‌شده را بدون بررسی وضعیت/کیف پول resume می‌کند. <code>triggerDeployment</code> (دیپلوی اول) گارد بیلینگ ندارد. <span class="path">applications.controller.ts:375</span> <code>PATCH resources</code> ارتقا را بدون مسیر پرداخت انجام می‌دهد.</p>
</div>
<div class="finding">
<div class="head"><span class="badge sec">SECURITY</span><span class="title">تداخل namespace بین کاربران (۸ کاراکتر اول UUID)</span></div>
<p class="desc"><span class="path">kubernetes.service.ts:2687-2689</span><code>user-${userId.split('-')[0]}</code>؛ دو کاربر با ۸ کاراکتر اول یکسان namespace مشترک و دسترسی به workload/secret همدیگر می‌گیرند. همین مشکل در ایزوله‌سازی لاگ الستیک (<span class="path">elasticsearch.service.ts:676</span>).</p>
</div>
<h3>امنیتی (P1)</h3>
<ul>
<li><span class="badge sec">SECURITY</span> <code>gitToken</code> و <code>dbPassword</code> در پاسخ API برمی‌گردند (<span class="path">application.entity.ts:54,114</span>) — نیاز به <code>@Exclude</code>.</li>
<li><span class="badge risk">RISK</span> عملیات کیف پول بدون transaction/lock (<span class="path">billing.service.ts:210</span>) — کسر هم‌زمان می‌تواند overdraw کند.</li>
<li><span class="badge risk">RISK</span> اسکنر auto-renew idempotent نیست بین رپلیکاها (<span class="path">app-lifecycle.service.ts:39</span>) — دو پاد یک اپ را دوبار شارژ می‌کنند.</li>
<li><span class="badge bug">BUG</span> proration ارتقا همیشه نرخ ساعتی را استفاده می‌کند (<span class="path">billing.service.ts:755</span>) → ارتقای ماهانه/سالانه undercharge یا رایگان.</li>
<li><span class="badge sec">SECURITY</span> توکن‌ها در <code>localStorage</code> (<span class="path">frontend/src/lib/store.ts:43</span>) → در معرض XSS.</li>
<li><span class="badge sec">SECURITY</span> refresh token بدون rotation/ابطال و context جعل هویت روی refresh دوباره اعتبارسنجی نمی‌شود (<span class="path">auth.service.ts:165</span>).</li>
</ul>
<h3>ریسک‌های متوسط</h3>
<ul>
<li><span class="badge risk">RISK</span> OTP با <code>Math.random()</code> به‌جای CSPRNG (<span class="path">verification.service.ts:188</span>) و race در مصرف OTP (خط ۲۲۵).</li>
<li><span class="badge risk">RISK</span> Swagger بی‌قید در پروداکشن باز است (<span class="path">main.ts:53</span>).</li>
<li><span class="badge risk">RISK</span> secretهای پیش‌فرض ضعیف خارج از پروداکشن (<span class="path">configuration.ts:75</span><code>default-jwt-secret</code>).</li>
</ul>
</div>
<div class="section pagebreak">
<h2>اولویت‌بندی برای پروداکشن</h2>
<div class="summary-box">
<h3 style="margin-top:0;">باید قبل از هر دیپلوی پروداکشن رفع شود (بلاکر)</h3>
<ol>
<li>حذف/گیت کردن <code>POST /billing/wallet/charge</code> پشت درگاه پرداخت واقعی.</li>
<li>گارد بیلینگ روی <code>triggerDeployment</code>، <code>startDeployment</code> و <code>PATCH resources</code>.</li>
<li>ساخت namespace از کل UUID، نه ۸ کاراکتر اول (تداخل بین‌مستأجری).</li>
<li>سیستم migration: جدول ردیابی نسخه یا SQL کاملاً idempotent + هوک <code>pre-upgrade</code> + base schema برای نصب تازه.</li>
<li>اصلاح <code>015</code> (<code>user_id</code><code>userId</code>) و گارد <code>duplicate_object</code> برای <code>CREATE TYPE</code> در <code>001</code>.</li>
<li>commit و deploy اصلاحات uncommit شده workflow (توکن Gitea + auth Harbor کانیکو).</li>
<li>rotate کردن رمزهای هاردکد الستیک‌سرچ.</li>
<li>رفع سینتکس <code>COPY</code> در Dockerfile گو و حذف <code>|| echo</code> از بیلد Node.</li>
</ol>
</div>
<table>
<thead><tr><th style="width:60px;">اولویت</th><th>اقدام</th></tr></thead>
<tbody>
<tr><td class="prio-num">۹</td><td>الگوی <code>lookup</code> برای رمز Redis/RabbitMQ (توقف چرخش رمز).</td></tr>
<tr><td class="prio-num">۱۰</td><td>probe رِدیس/مونگو با احراز هویت.</td></tr>
<tr><td class="prio-num">۱۱</td><td>آینه‌کردن base imageهای بیلد + ایمیج دیتابیس‌ها برای شبکه ایران.</td></tr>
<tr><td class="prio-num">۱۲</td><td>transaction/lock روی عملیات کیف پول.</td></tr>
<tr><td class="prio-num">۱۳</td><td><code>strategy: Recreate</code> روی سرویس‌های stateful و <code>RollingUpdate</code> روی backend.</td></tr>
<tr><td class="prio-num">۱۴</td><td>رفع ImagePullBackOff در fallback بین‌کلاستری.</td></tr>
<tr><td class="prio-num">۱۵</td><td>حذف <code>gitToken</code>/<code>dbPassword</code> از پاسخ‌ها با <code>@Exclude</code>.</td></tr>
<tr><td class="prio-num">۱۶</td><td>اعتبارسنجی و کوت <code>gitBranch</code>، انتقال توکن گیت به Secret.</td></tr>
<tr><td class="prio-num">۱۷</td><td>پشتیبانی MongoDB در snapshot، restore وردپرس از PVC به‌جای Secret.</td></tr>
<tr><td class="prio-num">۱۸</td><td>اجبار MySQL برای رانتایم WordPress.</td></tr>
<tr><td class="prio-num">۱۹</td><td>اجرای تست در مسیر Gitea قبل از دیپلوی.</td></tr>
<tr><td class="prio-num">۲۰</td><td>resource limits و backup پستگرس روی کنترل‌پلین.</td></tr>
</tbody>
</table>
</div>
<footer>
گزارش بررسی فنی CloudHost — تولید خودکار · محرمانه
</footer>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer-core';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const htmlPath = path.join(__dirname, 'audit-report.fa.html');
const pdfPath = path.join(root, 'AUDIT-REPORT.fa.pdf');
const chromePaths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
];
const executablePath = process.env.CHROME_PATH || chromePaths.find((p) => fs.existsSync(p));
if (!executablePath) {
console.error('Chrome/Chromium not found. Install Google Chrome or set CHROME_PATH.');
process.exit(1);
}
const browser = await puppeteer.launch({
executablePath,
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' });
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
margin: { top: '14mm', right: '13mm', bottom: '14mm', left: '13mm' },
});
await browser.close();
console.log(`Created: ${pdfPath}`);
+8
View File
@@ -0,0 +1,8 @@
{
"name": "cloudhost-pdf-scripts",
"private": true,
"type": "module",
"dependencies": {
"puppeteer-core": "^24.0.0"
}
}