23 Commits

Author SHA1 Message Date
keyhan 54ab2f2f05 Fix Kaniko registry auth and push target for Harbor builds.
Mount docker config as config.json (Kaniko requirement), push via
harbor-registry internal URL, and wire harbor_registry_user credentials
in Helm/GitOps values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 12:29:23 +03:30
keyhan 214b617be0 Fix image pull/build failures on restricted egress clusters.
Use seeded abrban/ images instead of flaky proxy-gcr pulls, fix Kaniko dockerfile path for v1.27, correct docker auth host keys, route /v2/abrban/ through harbor-core, and prefer abrban/ for base images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 20:08:16 +03:30
keyhan 2679c9d66e Fix Kaniko image refs to use seeded abrban/ images.
v1.27.6-debug does not exist on gcr.io; proxy-gcr pulls are flaky on kubelet. Seed and reference abrban/kaniko-executor:v1.23.2 instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:31:37 +03:30
keyhan 1ec4d07939 Add egress proxy to user-app Kaniko build jobs.
Inject registry-egress-proxy into Kaniko and network init containers so npm/apk/composer/pip/git clone work on restricted egress clusters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:07:48 +03:30
keyhan 3d773a4a62 feat(platform): wire OTP SMS env from platform Secret via Helm
Build and Deploy Platform / build-and-deploy (push) Successful in 32m19s
Enable backend.sms in the chart so MizbanSMS credentials from the platform
Secret are injected into the backend deployment for production OTP delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:05:08 +03:30
keyhan b2ecdad53b fix(logging): use ELASTIC_PASSWORD env in ES health probes
Build and Deploy Platform / build-and-deploy (push) Failing after 30m14s
Hardcoded Basic auth in probes broke after password rotation; exec probes
read the live secret so Elasticsearch stays healthy when credentials change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 19:41:19 +03:30
keyhan a2fe61b1f6 fix(platform): inject ELASTIC_PASSWORD from platform secrets in production
Build and Deploy Platform / build-and-deploy (push) Successful in 16m13s
Backend validate-production-config requires a non-default ELASTIC_PASSWORD;
read it from abrban-platform-secrets.elastic-password like other credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 19:24:34 +03:30
keyhan d3bbc0c0a0 fix(build): default Kaniko and init images to Harbor via Helm values
Build and Deploy Platform / build-and-deploy (push) Successful in 14m57s
User-app builds no longer pull gcr.io/docker.io directly when build.images
is configured in values.yaml, fixing ImagePullBackOff on clusters without
upstream registry access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 14:20:36 +03:30
keyhan 8163665c86 fix(platform): close remaining audit findings from security review
Harden preview/deploy flows, OTP generation, zip extraction, and multi-replica billing races; document full remediation status in AUDIT-STATUS.fa.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 12:30:22 +03:30
keyhan 6d9cd89cc5 docs: add portable from-zero deploy runbook and GitOps templates
Document server-side rollout (values, Sealed Secrets, logging, greenfield
reset) with environment variables so any cluster can follow the same steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 12:17:55 +03:30
keyhan 22359be40e 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>
2026-07-02 19:35:07 +03:30
keyhan 34c110be6a docs: document CI/CD bootstrap, seed images, and troubleshooting
Build and Deploy Platform / build-and-deploy (push) Successful in 8m9s
Add bootstrap checklist (seed-ci-images, Sealed Secrets, two-repo GitOps),
Kaniko BASE_IMAGE/insecure-pull details, platform secret requirements, and
updated gitops install order. Link RUNBOOK-CICD from README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:21:48 +03:30
keyhan 38b4a67db1 ci: add --insecure-pull for kaniko to pull base image from harbor-registry:5000
Build and Deploy Platform / build-and-deploy (push) Successful in 16m17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:38:44 +03:30
keyhan 7695cb5420 ci: use internal harbor-registry URL for BASE_IMAGE pull (matches kaniko auth)
Build and Deploy Platform / build-and-deploy (push) Failing after 47s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:35:44 +03:30
keyhan 6ba77eebcf ci: pull node base image from Harbor instead of docker.io
Build and Deploy Platform / build-and-deploy (push) Failing after 47s
Kaniko builds failed with context deadline exceeded pulling node:24-alpine
from index.docker.io through the egress proxy. Seed node:24-alpine into
abrban/ and pass BASE_IMAGE build-arg so builds use the internal registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:32:41 +03:30
keyhan f2e8195d1d ci: retry kaniko jobs (backoffLimit 2, push-retry) for flaky egress to docker.io
Build and Deploy Platform / build-and-deploy (push) Failing after 17m55s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:53:48 +03:30
keyhan ba82a5d785 ci: fail fast on kaniko job failure with logs, longer job TTL for post-mortem
Build and Deploy Platform / build-and-deploy (push) Failing after 15m40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:35:07 +03:30
keyhan 1572b3ce66 fix(chart): support existingSecret and provide CLUSTER_KUBECONFIG_KEY to backend
Build and Deploy Platform / build-and-deploy (push) Failing after 50m25s
Backend now fails production validation without CLUSTER_KUBECONFIG_KEY.
Add cluster-kubeconfig-key to the chart secret and env, plus
secrets.existingSecret so GitOps deployments can use a pre-created
(sealed) Secret instead of the lookup/randAlphaNum template that churns
under Argo CD's helm template rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:39:06 +03:30
keyhan abfe858909 ci: use seeded abrban/alpine-git for clone init (proxy-dockerhub 404s for kubelet)
Build and Deploy Platform / build-and-deploy (push) Successful in 37m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:36:37 +03:30
keyhan e05e5e54ca ci: fix kaniko base-image pulls via egress proxy, use alpine/git for clone, longer build timeout
Build and Deploy Platform / build-and-deploy (push) Waiting to run
- Kaniko needs the egress proxy to pull node:24-alpine from docker.io
  (envFrom registry-egress-proxy; NO_PROXY keeps harbor push direct)
- Replace flaky apk-add-git init with alpine/git image
- Dump kaniko logs into workflow output on build timeout/failure

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:30:44 +03:30
keyhan 7e66d1edf3 ci: split GitOps state into cloud-host-gitops repo, add Sealed Secrets, fix pipeline auth
Build and Deploy Platform / build-and-deploy (push) Failing after 20m1s
- Workflow now pushes image tags to the separate cloud-host-gitops repo
  (no more CI loop risk) and authenticates via CI_TOKEN secret
- Fix undefined ${REGISTRY} in Kaniko jobs, add concurrency group,
  targeted tag update, and mounted kaniko-harbor-auth docker config
- Argo CD Application is now multi-source (chart from cloud-host,
  values from cloud-host-gitops)
- Remove plaintext runner token and proxy credentials from manifests;
  secrets are now SealedSecrets in the gitops repo

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 14:10:46 +03:30
keyhan d4559920d1 Fix CI: use git clone instead of actions/checkout, host mode without GitHub access.
Build and Deploy Platform / build-and-deploy (push) Failing after 20m1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 02:07:40 +03:30
keyhan c97152fa9e Fix CI runner: seed images to abrban/, kubectl via alpine init, Kaniko Jobs for builds.
Build and Deploy Platform / build-and-deploy (push) Failing after 15s
Harbor proxy-cache works from core API but containerd can't use it directly,
so all runner images are seeded to abrban/ project.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 02:04:49 +03:30
84 changed files with 6999 additions and 689 deletions
+267 -40
View File
@@ -1,61 +1,288 @@
name: Build and Deploy Platform
on:
push:
branches: [main, master]
branches: [main]
paths-ignore:
- "**.md"
workflow_dispatch:
# Serialize builds so parallel pushes don't race on the GitOps values update.
concurrency:
group: build-deploy-platform
cancel-in-progress: false
env:
# Push via internal Harbor registry (no creds needed from runner pod)
REGISTRY_INTERNAL: harbor-registry.cloudhost.svc.cluster.local:5000
REGISTRY: registry.abrban.com
BACKEND_IMAGE: abrban/cloudhost-backend
FRONTEND_IMAGE: abrban/cloudhost-frontend
# PULL_REGISTRY: kubelet pulls via k3s mirror → harbor-core (matches registry-pull-secret)
PULL_REGISTRY: registry.abrban.com
# PUSH_REGISTRY: kaniko pushes directly to harbor-registry (internal, no TLS)
PUSH_REGISTRY: harbor-registry.cloudhost.svc.cluster.local:5000
PROJECT: abrban
BUILD_NS: cloudhost-builds
GITEA_HOST: gitea-http.gitea.svc.cluster.local:3000
# PAT of the "ci" user, stored as repo secret CI_TOKEN (names starting with GITEA_ are reserved)
GITEA_TOKEN: ${{ secrets.CI_TOKEN }}
REPO_PATH: abrban/cloud-host.git
GITOPS_REPO_PATH: abrban/cloud-host-gitops.git
jobs:
build-push-deploy:
runs-on: abrban-kaniko
build-and-deploy:
runs-on: abrban-builder
steps:
- name: Checkout
uses: actions/checkout@v4
shell: sh
run: |
git clone --depth=1 --branch main "http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH}" workspace
cd workspace
echo "Checked out $(git rev-parse --short HEAD)"
- name: Set image tag
shell: bash
shell: sh
run: |
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
echo "IMAGE_TAG=$(date +%Y%m%d-%H%M)-${SHA:0:8}" >> "$GITHUB_ENV"
cd workspace
SHA="$(git rev-parse --short HEAD)"
TAG="$(date +%Y%m%d-%H%M)-${SHA}"
echo "IMAGE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "Build tag: ${TAG}"
- name: Build backend (Kaniko)
- name: Define job waiter
shell: sh
run: |
/kaniko/executor \
--context=./backend \
--dockerfile=./backend/Dockerfile \
--destination="${REGISTRY_INTERNAL}/${BACKEND_IMAGE}:${IMAGE_TAG}" \
--insecure \
--skip-tls-verify
# wait_for_job <name>: exit 0 on Complete, exit 1 (with kaniko logs) on Failed/timeout
cat > wait_for_job.sh <<'ENDSCRIPT'
#!/bin/sh
JOB="$1"
DEADLINE=$(( $(date +%s) + 2400 ))
while :; do
CONDS="$(kubectl -n ${BUILD_NS} get job/${JOB} -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}' 2>/dev/null)"
case "$CONDS" in
*Complete=True*) echo "Job ${JOB} completed"; exit 0 ;;
*Failed=True*)
echo "Job ${JOB} FAILED — kaniko logs:"
kubectl -n ${BUILD_NS} logs job/${JOB} -c kaniko --tail=100 || true
exit 1 ;;
esac
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
echo "Timed out waiting for job ${JOB} — kaniko logs:"
kubectl -n ${BUILD_NS} logs job/${JOB} -c kaniko --tail=100 || true
exit 1
fi
sleep 15
done
ENDSCRIPT
chmod +x wait_for_job.sh
- name: Build frontend (Kaniko)
- name: Run backend tests (Job)
shell: sh
run: |
/kaniko/executor \
--context=./frontend \
--dockerfile=./frontend/Dockerfile \
--build-arg=NEXT_PUBLIC_API_URL=https://api.abrban.com \
--destination="${REGISTRY_INTERNAL}/${FRONTEND_IMAGE}:${IMAGE_TAG}" \
--insecure \
--skip-tls-verify
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: Update GitOps values
shell: bash
- name: Build backend image (Kaniko Job)
shell: sh
run: |
sed -i "s|tag: \"[^\"]*\"|tag: \"${IMAGE_TAG}\"|g" gitops/platform/values-abrban.yaml
JOB_NAME="build-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: 2
template:
spec:
restartPolicy: Never
imagePullSecrets:
- name: registry-pull-secret
initContainers:
- name: clone
# alpine/git ships git — no flaky apk install at build time
image: ${PULL_REGISTRY}/${PROJECT}/alpine-git:2.43.0
command:
- sh
- -c
- git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace
volumeMounts:
- name: ws
mountPath: /workspace
containers:
- name: kaniko
image: ${PULL_REGISTRY}/${PROJECT}/kaniko-executor:v1.23.2
# Base image (node:24-alpine) is seeded in Harbor abrban/ — avoids
# flaky direct pulls from docker.io through the egress proxy.
envFrom:
- secretRef:
name: registry-egress-proxy
args:
- --dockerfile=/workspace/backend/Dockerfile
- --context=dir:///workspace/backend
- --build-arg=BASE_IMAGE=${PUSH_REGISTRY}/${PROJECT}/node:24-alpine
- --destination=${PUSH_REGISTRY}/${PROJECT}/cloudhost-backend:${IMAGE_TAG}
- --insecure
- --insecure-pull
- --insecure-registry=${PUSH_REGISTRY}
- --skip-tls-verify
- --push-retry=2
volumeMounts:
- name: ws
mountPath: /workspace
- name: docker-config
mountPath: /kaniko/.docker
volumes:
- name: ws
emptyDir: {}
- name: docker-config
secret:
secretName: kaniko-harbor-auth
items:
- key: .dockerconfigjson
path: config.json
ENDJOB
echo "Waiting for backend build job: ${JOB_NAME}"
./wait_for_job.sh "${JOB_NAME}"
echo "Backend build done"
- name: Build frontend image (Kaniko Job)
shell: sh
run: |
JOB_NAME="build-fe-$(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: 2
template:
spec:
restartPolicy: Never
imagePullSecrets:
- name: registry-pull-secret
initContainers:
- name: clone
image: ${PULL_REGISTRY}/${PROJECT}/alpine-git:2.43.0
command:
- sh
- -c
- git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace
volumeMounts:
- name: ws
mountPath: /workspace
containers:
- name: kaniko
image: ${PULL_REGISTRY}/${PROJECT}/kaniko-executor:v1.23.2
envFrom:
- secretRef:
name: registry-egress-proxy
args:
- --dockerfile=/workspace/frontend/Dockerfile
- --context=dir:///workspace/frontend
- --build-arg=BASE_IMAGE=${PUSH_REGISTRY}/${PROJECT}/node:24-alpine
- --build-arg=NEXT_PUBLIC_API_URL=https://api.abrban.com
- --destination=${PUSH_REGISTRY}/${PROJECT}/cloudhost-frontend:${IMAGE_TAG}
- --insecure
- --insecure-pull
- --insecure-registry=${PUSH_REGISTRY}
- --skip-tls-verify
- --push-retry=2
volumeMounts:
- name: ws
mountPath: /workspace
- name: docker-config
mountPath: /kaniko/.docker
volumes:
- name: ws
emptyDir: {}
- name: docker-config
secret:
secretName: kaniko-harbor-auth
items:
- key: .dockerconfigjson
path: config.json
ENDJOB
echo "Waiting for frontend build job: ${JOB_NAME}"
./wait_for_job.sh "${JOB_NAME}"
echo "Frontend build done"
- name: Update GitOps repo and push
shell: sh
run: |
git clone --depth=1 --branch main "http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO_PATH}" gitops-repo
cd gitops-repo
VALUES=platform/values-abrban.yaml
if command -v yq >/dev/null 2>&1; then
IMAGE_TAG="${IMAGE_TAG}" yq -i '.images.backend.tag = strenv(IMAGE_TAG) | .images.frontend.tag = strenv(IMAGE_TAG)' "${VALUES}"
else
# Only touch the tag line directly below each cloudhost-* repository line.
sed -i "/repository: .*cloudhost-backend/{n;s|tag: \".*\"|tag: \"${IMAGE_TAG}\"|;}" "${VALUES}"
sed -i "/repository: .*cloudhost-frontend/{n;s|tag: \".*\"|tag: \"${IMAGE_TAG}\"|;}" "${VALUES}"
fi
git config user.email "ci@abrban.com"
git config user.name "Gitea Actions"
git add gitops/platform/values-abrban.yaml
git diff --cached --quiet || git commit -m "ci: deploy platform ${IMAGE_TAG}"
- name: Push GitOps update
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@git.abrban.com/abrban/cloud-host.git"
git push origin HEAD:main
git add "${VALUES}"
if ! git diff --cached --quiet; then
git commit -m "ci: deploy platform ${IMAGE_TAG}"
# 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
+8
View File
@@ -25,6 +25,14 @@ jobs:
- run: npm run lint:check
- run: npm test -- --passWithNoTests
- 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:
name: Frontend
Binary file not shown.
+172
View File
@@ -0,0 +1,172 @@
# وضعیت رفع یافته‌های گزارش ممیزی CloudHost
> مرجع: `AUDIT-REPORT.fa.pdf` / `scripts/audit-report.fa.html`
> آخرین به‌روزرسانی: ۳ تیر ۱۴۰۴ (3 Jul 2026)
| نماد | معنی |
|------|------|
| ✅ | رفع شده |
| ⚠️ | جزئی / نیاز به پیکربندی محیط |
| 🔜 | عمداً به تعویق افتاده (اسکوپ بزرگ یا trade-off) |
---
## خلاصه
| دسته | تعداد | ✅ | ⚠️ | 🔜 |
|------|-------|----|----|-----|
| بلاکرهای پروداکشن | 8 | 8 | 0 | 0 |
| بیلد و Kaniko | 10 | 9 | 0 | 1 |
| دیپلوی و پیش‌نمایش | 6 | 6 | 0 | 0 |
| دیتابیس / سرویس اختیاری | 9 | 8 | 0 | 1 |
| Migration / اسکیما | 4 | 4 | 0 | 0 |
| بیلینگ و امنیت مالی | 5 | 5 | 0 | 0 |
| GitOps / CI-CD | 4 | 4 | 0 | 0 |
| امنیت اپ / auth | 6 | 3 | 0 | 3 |
| بهبود / زیرساخت | 5 | 2 | 1 | 2 |
**نتیجه:** همه بلاکرهای پروداکشن و تقریباً همه باگ‌های قطعی رفع شده‌اند. موارد باقی‌مانده عمدتاً پیکربندی آینه رجیستری، JWT در localStorage، و پاک‌سازی PVC یتیم هستند.
---
## ۱. بلاکرهای پروداکشن (اولویت ۱–۸)
| # | یافته | وضعیت | اقدام |
|---|--------|--------|-------|
| 1 | شارژ رایگان کیف پول (`POST /billing/wallet/charge`) | ✅ | HMAC + گارد production در `billing-wallet.controller.ts` |
| 2 | دیپلوی بدون پرداخت | ✅ | گارد بیلینگ در `triggerDeployment`, `startDeployment`, `PATCH resources` |
| 3 | namespace از ۸ کاراکتر UUID | ✅ | `userIdSlug` / `userNamespace` با UUID کامل |
| 4 | migration بدون ردیابی نسخه | ✅ | `schema_migrations` + `000_base_schema.sql` + pre-upgrade hook |
| 5 | `015` ستون `user_id` / `001` بدون گارد TYPE | ✅ | اصلاح نام ستون + `IF NOT EXISTS` |
| 6 | workflow Gitea بدون تست | ✅ | job تست + rebase در `.gitea/workflows/build-deploy.yaml` |
| 7 | رمز هاردکد Elasticsearch | ✅ | حذف از git + SealedSecret |
| 8 | COPY گو / `\|\| echo` Node | ✅ | اصلاح در `build.service.ts` |
---
## ۲. بیلد (Kaniko + Dockerfile)
| یافته | وضعیت | یادداشت |
|--------|--------|---------|
| Go COPY نامعتبر | ✅ | |
| Node build failure نادیده | ✅ | `npm ci` + fail-on-build |
| Laravel extensions | ✅ | mbstring, xml, bcmath, zip, fileinfo, tokenizer |
| Python pyproject.toml | ✅ | تشخیص + نصب poetry/pdm |
| Kaniko 4Gi / PVC بدون SC | ✅ | limits قابل تنظیم + `BUILD_PVC_STORAGE_CLASS` |
| Git token در spec / branch injection / SSRF | ✅ | GIT_ASKPASS + Secret + validation |
| Zip slip در unzip | ✅ | اعتبارسنجی مسیر قبل و بعد از extract |
| state بیلد در حافظه | ✅ | Redis session + startup recovery |
| دیپلوی هم‌زمان بدون قفل | ✅ | in-flight guard در `triggerDeployment` |
| Base image بدون آینه | ✅ | `build.images` در Helm values + پیش‌فرض Harbor در configuration |
| zip bomb (۱۰GiB) | 🔜 | سقف آپلود موجود؛ محدودیت تعداد entry در archive پیشنهاد می‌شود |
---
## ۳. دیپلوی و پیش‌نمایش
| یافته | وضعیت | یادداشت |
|--------|--------|---------|
| حذف preview با custom domain pending | ✅ | `hasVerifiedCustomDomain()` در k8s + deployments |
| getPreviewInfo پچ NodePort | ✅ | فقط خواندن؛ ingressUrl اولویت دارد |
| fallback بین‌کلاستری → ImagePullBackOff | ✅ | `CLUSTER_DEPLOY_FALLBACK_ENABLED=true` برای fallback |
| NodePort host از API server | ⚠️ | `getClusterHostIp` همچنان fallback؛ ingressUrl مسیر اصلی |
| suspend NodePort revoke | ✅ | `deleteTemporaryAccessServicesForApp` در suspend |
---
## ۴. دیتابیس و سرویس‌های اختیاری
| یافته | وضعیت | یادداشت |
|--------|--------|---------|
| Redis/RabbitMQ randAlphaNum | ✅ | الگوی `lookup` در helm templates |
| probe بدون auth | ✅ | redis-cli `-a` / mongo با credential |
| RWO بدون Recreate | ✅ | `strategy: Recreate` |
| dbPassword fallback هر deploy | ✅ | generate + persist در DB |
| Mongo snapshot/restore | ✅ | |
| WordPress MySQL اجباری | ✅ | |
| wp-content restore از PVC | ✅ | |
| PVC یتیم بعد از suspend/delete | 🔜 | نیاز به job پاک‌سازی دوره‌ای |
| ایمیج DB از Docker Hub | ⚠️ | آینه در `values.yaml`؛ پیکربندی per-cluster |
---
## ۵. Migration / اسکیما
| یافته | وضعیت |
|--------|--------|
| Job دوباره همه SQL | ✅ |
| post-upgrade → pre-upgrade | ✅ |
| نبود base schema | ✅ |
| 015 user_id | ✅ |
---
## ۶. بیلینگ
| یافته | وضعیت |
|--------|--------|
| wallet charge بدون درگاه | ✅ |
| proration اشتباه | ✅ |
| race در wallet | ✅ | pessimistic lock |
| auto-renew دو بار بین replicas | ✅ | lock روی Application در transaction |
| دیپلوی بدون پرداخت | ✅ |
---
## ۷. GitOps / CI-CD
| یافته | وضعیت |
|--------|--------|
| workflow بدون تست | ✅ |
| elastic password در git | ✅ |
| platform Redis requirepass | ✅ |
| backend RollingUpdate + limits + postgres backup | ✅ |
| Swagger در production | ✅ |
| RUNBOOK-DEPLOY portable | ✅ | commit `6d9cd89` |
---
## ۸. امنیت اپلیکیشن
| یافته | وضعیت | یادداشت |
|--------|--------|---------|
| gitToken / dbPassword در API | ✅ | `@Exclude` + `hasDbPassword` / `hasGitToken` |
| Elasticsearch log isolation | ✅ | namespace کامل |
| OTP Math.random | ✅ | `crypto.randomInt` |
| OTP consume race | ✅ | pessimistic lock در transaction |
| JWT در localStorage | 🔜 | نیاز به httpOnly cookie + CSRF — اسکوپ frontend بزرگ |
| refresh token rotation | 🔜 | |
| secret پیش‌فرض dev | ⚠️ | `validate-production-config` در production fail می‌کند |
| docker compose NODE_ENV=production | ✅ | `NODE_ENV: development` برای dev محلی |
---
## ۹. بهبود / زیرساخت
| یافته | وضعیت |
|--------|--------|
| Backend Dockerfile helm/kubectl از اینترنت | 🔜 | mirror یا COPY از stage |
| orphan PVC cleanup | 🔜 |
| zip bomb hard limit | 🔜 |
---
## متغیرهای محیطی جدید (مرجع deploy)
| متغیر | پیش‌فرض | توضیح |
|--------|---------|-------|
| `CLUSTER_DEPLOY_FALLBACK_ENABLED` | `false` | fallback بین کلاستر |
| `CLUSTER_DEPLOY_FALLBACK_ATTEMPTS` | `3` | فقط وقتی fallback فعال |
| `BASE_IMAGE_REGISTRY` | `registry.abrban.com/proxy-dockerhub/library` | آینه base imageهای بیلد (یا از `build` در Helm values) |
| `KANIKO_IMAGE` | `registry.abrban.com/proxy-gcr/...` | Kaniko executor (یا `build.images.kaniko` در values) |
| `BUILD_PVC_STORAGE_CLASS` | — | StorageClass برای PVC بیلد |
| `BILLING_WALLET_HMAC_SECRET` | — | اجباری در production |
| `PAYMENT_GATEWAY_*` | — | اجباری برای charge واقعی |
---
## commitهای مرتبط
- `22359be` — fix(platform): apply production hardening from audit plan
- `6d9cd89` — docs: add portable from-zero deploy runbook and GitOps templates
- *(uncommitted)* — رفع موارد باقی‌مانده این سند (preview، OTP، zip slip، auto-renew lock، docker-compose)
+17 -4
View File
@@ -13,6 +13,10 @@ custom `wp-content` entrypoint).
> 🇮🇷 Production deployment on the `abrban.com` k3s cluster — including all the
> Iran-network workarounds — is documented step-by-step in **[RUNBOOK.fa.md](RUNBOOK.fa.md)** (Persian).
> 🔄 **CI/CD (Gitea Actions → Kaniko → Harbor → Argo CD):** see **[RUNBOOK-CICD.fa.md](RUNBOOK-CICD.fa.md)** (Persian) and **[gitops/README.md](gitops/README.md)** for bootstrap (`seed-ci-images`, Sealed Secrets, two-repo GitOps layout).
>
> 🚀 **Deploy from zero (any cluster):** **[RUNBOOK-DEPLOY.fa.md](RUNBOOK-DEPLOY.fa.md)** — server checklist, values, secrets, logging, greenfield reset.
---
## Architecture Overview
@@ -109,6 +113,8 @@ cloud-host/
├── README.md # This file
├── ARCHITECTURE.md # Detailed system design
├── RUNBOOK.fa.md # Persian runbook: local dev + abrban/k3s production deploy
├── RUNBOOK-DEPLOY.fa.md # Deploy platform from zero (any cluster): values, secrets, health checks
├── RUNBOOK-CICD.fa.md # CI/CD pipeline: Gitea Actions → Kaniko → Argo CD
├── CHANGELOG.md / CONTRIBUTING.md / UPGRADE.md / UPGRADE.en.md
├── docker-compose.yml # Local dev stack (Postgres + Redis + API + UI)
@@ -209,9 +215,13 @@ to the backend URL.
## Deploy on Kubernetes (Helm)
> This is the **generic** path. For the production `abrban.com` k3s cluster — base-image
> mirroring, the Iran-network proxy/npmmirror, the wildcard TLS cert, registry bootstrap,
> and the exact image-build flow — follow **[RUNBOOK.fa.md](RUNBOOK.fa.md)**.
> **Production GitOps (from zero):** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — variable table, values, Sealed Secrets, logging, health checks.
>
> **Production abrban.com specifics:** [`RUNBOOK.fa.md`](RUNBOOK.fa.md) — Iran network, Ceph, Harbor details.
>
> **CI/CD pipeline:** [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md).
This section is the **generic Helm-only** path (Path B in RUNBOOK-DEPLOY) without Gitea/Argo.
**Prerequisites:** a Kubernetes cluster, an Ingress controller (Traefik on k3s by default,
or set `INGRESS_CLASS=nginx`), a default StorageClass for PVCs, and a container registry
@@ -277,7 +287,10 @@ curl -s -o /dev/null -w '%{http_code}\n' https://<frontend.host>
| `MIZBANSMS_USERNAME` / `MIZBANSMS_PASSWORD` / `MIZBANSMS_FROM` | OTP SMS credentials (required or OTP send 503s) | — |
| `REGISTRY_URL` / `REGISTRY_PULL_URL` | In-cluster registry (push / pull) | `registry.cloudhost-builds.svc.cluster.local:5000` |
| `BUILD_NAMESPACE` / `BUILD_SERVICE_ACCOUNT` | Build Jobs namespace + SA | `cloudhost-builds` / `kaniko-builder` |
| `KANIKO_IMAGE` | Kaniko executor image | `gcr.io/kaniko-project/executor:v1.23.2` |
| `KANIKO_IMAGE` | Kaniko executor image | `registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2` |
| `BUILD_ALPINE_IMAGE` | Alpine image for build init/helper pods | `registry.abrban.com/proxy-dockerhub/library/alpine:3.19` |
| `BUILD_ALPINE_GIT_IMAGE` | Git-clone init container image | `registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0` |
| `BASE_IMAGE_REGISTRY` | Harbor prefix for Docker Hub images in generated Dockerfiles | `registry.abrban.com/proxy-dockerhub/library` |
| `UPLOAD_DIR` | Disk path for uploaded source archives | `./uploads` |
| `INGRESS_CLASS` | Ingress controller for app Ingress objects | `traefik` |
| `PLATFORM_DOMAIN` / `PREVIEW_BASE_DOMAIN` | Base domain for app subdomains / previews | `apps.cloudhost.local` / — |
+321
View File
@@ -0,0 +1,321 @@
# RUNBOOK — خط CI/CD (Gitea Actions → Kaniko → Harbor → Argo CD)
این مستند جریان کامل Build و Deploy پلتفرم را توضیح می‌دهد: از Push شدن کد روی `main` تا استقرار خودکار روی Kubernetes.
> **استقرار از صفر روی سرور جدید:** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — شامل جدول متغیرها، seal کردن Secretها، logging stack، greenfield reset، و چک‌لیست سلامت.
---
## معماری و جریان کلی
```mermaid
flowchart TD
Dev[Developer] -->|git push main| AppRepo["Gitea: abrban/cloud-host (کد + چارت)"]
AppRepo -->|trigger workflow| Runner["Act Runner (namespace: gitea)"]
Runner -->|"checkout با CI_TOKEN"| AppRepo
Runner -->|kubectl apply Job| Kaniko["Kaniko Job (namespace: cloudhost-builds)"]
Kaniko -->|"push با harbor_registry_user"| Harbor["Harbor (harbor-registry:5000)"]
Runner -->|"آپدیت image.tag + commit/push"| GitOpsRepo["Gitea: abrban/cloud-host-gitops (state)"]
GitOpsRepo -->|"poll (پیش‌فرض هر ۳ دقیقه)"| Argo["Argo CD (automated sync)"]
AppRepo -->|"Helm Chart (source دوم)"| Argo
Argo -->|"helm render + apply"| K8s["Kubernetes (namespace: cloudhost)"]
Harbor -->|"pull از طریق mirror در k3s"| K8s
Rollback["Rollback: git revert در cloud-host-gitops"] -.-> GitOpsRepo
```
مراحل به ترتیب:
1. Developer روی شاخهٔ `main` در ریپوی اپلیکیشن (`git.abrban.com/abrban/cloud-host`) push می‌کند.
2. Workflow در [`.gitea/workflows/build-deploy.yaml`](.gitea/workflows/build-deploy.yaml) روی Runner با لیبل `abrban-builder` اجرا می‌شود.
3. Runner کد را با توکن CI کلون می‌کند و تگ ایمیج (`YYYYMMDD-HHMM-<sha>`) را می‌سازد.
4. **Job تست بک‌اند** در namespace `cloudhost-builds` اجرا می‌شود (`npm ci` + `jest --ci`) — در صورت fail، بیلد ایمیج شروع نمی‌شود.
5. برای هر ایمیج (backend و frontend) یک Kaniko Job در namespace `cloudhost-builds` ساخته می‌شود که کد را کلون، ایمیج را build و به Harbor push می‌کند.
6. بعد از موفقیت هر دو Build، همان Runner ریپوی **`cloud-host-gitops`** را کلون می‌کند، مقدار `images.backend.tag` و `images.frontend.tag` را در `platform/values-abrban.yaml` عوض و commit/push می‌کند (با retry و `git pull --rebase` در صورت race).
7. Argo CD (Application به نام `abrban-platform` با sync خودکار) تغییر را تشخیص می‌دهد و نسخهٔ جدید را در namespace `cloudhost` مستقر می‌کند.
> **جلوگیری از حلقهٔ CI:** کامیتِ Pipeline به ریپوی جدا (`cloud-host-gitops`) می‌رود که هیچ Workflowای ندارد؛ بنابراین Build دوباره trigger نمی‌شود.
**زمان تقریبی یک Pipeline کامل:** ۱۵–۲۵ دقیقه (backend سنگین‌تر است؛ شامل دانلود npm، helm و kubectl داخل Dockerfile).
---
## Bootstrap — پیش‌نیازهای یک‌بار (کلاستر تازه)
قبل از اولین push به `main`، این موارد باید در کلاستر آماده باشند:
| # | کار | دستور / فایل |
|---|-----|--------------|
| 1 | Mirror k3s → Harbor | `./scripts/apply-k3s-registries.sh` |
| 2 | Secretهای TLS و registry در nsهای `gitea`, `cloudhost-builds`, `argocd` | [`gitops/README.md`](gitops/README.md) گام ۴ |
| 3 | پروکسی egress در `gitea` و `cloudhost-builds` | همان گام ۴ — برای npm/helm/kubectl داخل build و دانلود kubectl توسط Runner |
| 4 | **Seed ایمیج‌های CI** در Harbor `abrban/` | [`gitops/jobs/seed-ci-images.yaml`](gitops/jobs/seed-ci-images.yaml) |
| 5 | Sealed Secrets controller | `helm upgrade --install sealed-secrets ... -f gitops/sealed-secrets/values.yaml` |
| 6 | SealedSecretها از ریپوی gitops | `kubectl apply -f` روی `cloud-host-gitops/sealed-secrets/` |
| 7 | Gitea Runner + Secret `CI_TOKEN` در ریپو | [`gitops/gitea/act-runner.yaml`](gitops/gitea/act-runner.yaml) |
| 8 | ریپوی `cloud-host-gitops` + Argo Application | [`gitops/argocd/application-platform.yaml`](gitops/argocd/application-platform.yaml) |
### Seed ایمیج‌های CI (الزامی)
kubelet و Kaniko نمی‌توانند reliably از proxy-cache هاربر برای همهٔ ایمیج‌ها استفاده کنند. این ایمیج‌ها باید **یک‌بار** با skopeo در پروژهٔ `abrban/` کپی شوند:
| ایمیج در Harbor | منبع upstream | مصرف |
|-----------------|---------------|------|
| `abrban/act-runner:0.2.11` | docker.io/gitea/act_runner | Gitea Actions runner |
| `abrban/alpine-git:2.43.0` | docker.io/alpine/git | initContainer کلون در Kaniko Job |
| `abrban/node:24-alpine` | docker.io/library/node | **BASE_IMAGE** در Dockerfile (هر stage) |
| `abrban/kaniko-executor:v1.27.6-debug` | gcr.io/kaniko-project/executor | Kaniko Job |
```bash
# پیش‌نیاز: secret registry-egress-proxy و registry-pull-secret در ns cloudhost
kubectl apply -f gitops/jobs/seed-ci-images.yaml
kubectl -n cloudhost wait --for=condition=complete job/seed-ci-images --timeout=15m
kubectl -n cloudhost logs job/seed-ci-images --tail=5
# انتظار: SEED_OK
```
بررسی:
```bash
kubectl -n cloudhost run tags --rm -i --restart=Never \
--image=registry.abrban.com/abrban/alpine:3 \
--overrides='{"spec":{"imagePullSecrets":[{"name":"registry-pull-secret"}]}}' \
-- sh -c 'H="harbor_registry_user:$(kubectl -n cloudhost get secret harbor-core -o jsonpath="{.data.REGISTRY_CREDENTIAL_PASSWORD}" | base64 -d)@harbor-registry.cloudhost.svc.cluster.local:5000"; for r in act-runner kaniko-executor alpine-git node; do wget -qO- "http://${H}/v2/abrban/${r}/tags/list"; echo; done'
```
> بعد از bootstrap، Pipeline با push به `main` خودکار اجرا می‌شود؛ نیازی به `./scripts/trigger-platform-build.sh` برای جریان عادی نیست (فقط برای دیباگ دستی).
---
## ساختار Repository (دو ریپو)
### `abrban/cloud-host` — Application Repo
| مسیر | نقش |
|------|-----|
| `backend/`, `frontend/` | کد اپلیکیشن + Dockerfile |
| `backend/helm/cloudhost-platform/` | Helm Chart پلتفرم |
| `.gitea/workflows/build-deploy.yaml` | Pipeline (Build + آپدیت GitOps) |
| `gitops/` | نصب زیرساخت (Argo CD، Gitea، Sealed Secrets، k3s و…) |
### `abrban/cloud-host-gitops` — GitOps Repo (منبع حقیقت Argo CD)
| مسیر | نقش |
|------|-----|
| `platform/values-abrban.yaml` | مقادیر Production — تنها فایلی که CI آپدیت می‌کند |
| `argocd/application-platform.yaml` | تعریف Application (نسخهٔ mirror آن در `gitops/argocd/` ریپوی اپ هم هست) |
| `sealed-secrets/*.yaml` | SealedSecretهای CI — رمزشده و قابل کامیت |
Application در Argo CD به‌صورت **multi-source** تعریف شده: چارت از `cloud-host` و values از `cloud-host-gitops`:
```yaml
sources:
- repoURL: https://git.abrban.com/abrban/cloud-host.git
path: backend/helm/cloudhost-platform
helm:
valueFiles:
- $values/platform/values-abrban.yaml
- repoURL: https://git.abrban.com/abrban/cloud-host-gitops.git
ref: values
```
مزیت این جداسازی: history تمیز، دسترسی نوشتن CI محدود به ریپوی state، و امکان دیدن کل تاریخچهٔ Deployها با `git log` یک ریپوی کوچک.
---
## احراز هویت‌ها (چه کسی با چه چیزی به کجا وصل می‌شود)
| مسیر | مکانیزم | محل نگهداری |
|------|---------|--------------|
| Runner → Gitea (ثبت) | Registration Token | SealedSecret `gitea-act-runner-token` (ns `gitea`) در ریپوی gitops |
| Workflow → Gitea (clone/push هر دو ریپو) | PAT کاربر `ci` | Secret ریپوی `cloud-host` در Gitea با نام **`CI_TOKEN`** (نام‌های `GITEA_*` رزرو هستند) |
| Kaniko → Harbor (push) | `harbor_registry_user` | SealedSecret `kaniko-harbor-auth` (ns `cloudhost-builds`) در ریپوی gitops |
| kubelet → Harbor (pull) | user `cloudhost` | Secret `registry-pull-secret` + mirror در `gitops/k3s/registries.yaml` |
| Argo CD → `cloud-host` (read) | repo credential | Secret `gitea-repo-creds` (ns `argocd`) |
| Argo CD → `cloud-host-gitops` (read) | PAT کاربر `ci` | SealedSecret `gitea-gitops-repo-creds` (ns `argocd`) در ریپوی gitops |
### توکن CI برای Gitea (`CI_TOKEN`)
کاربر `ci` در Gitea ساخته شده و روی هر دو ریپو دسترسی write دارد. PAT آن با scope `read:repository, write:repository` به‌عنوان Secret با نام `CI_TOKEN` در **Settings → Actions → Secrets** ریپوی `cloud-host` ثبت شده است.
برای rotate: در Gitea با کاربر `ci` توکن جدید بسازید (یا از API ادمین: `POST /api/v1/users/ci/tokens`)، مقدار Secret را در تنظیمات ریپو آپدیت کنید و SealedSecret `gitea-gitops-repo-creds` را هم دوباره seal کنید.
### احراز هویت Kaniko به Harbor
Kaniko به endpoint داخلی `harbor-registry.cloudhost.svc.cluster.local:5000` push می‌کند که **مستقیم به کامپوننت registry** می‌رود و harbor-core را دور می‌زند. نکتهٔ مهم:
- **Robot Accountهای Harbor اینجا کار نمی‌کنند** — توکن آن‌ها را harbor-core صادر می‌کند و endpoint داخلی به سرویس توکن دسترسی ندارد.
- credential درست، کاربر داخلی `harbor_registry_user` است با پسورد `REGISTRY_CREDENTIAL_PASSWORD` از Secret `harbor-core`:
```bash
REG_PASS="$(kubectl -n cloudhost get secret harbor-core \
-o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)"
kubectl -n cloudhost-builds create secret docker-registry kaniko-harbor-auth \
--docker-server=harbor-registry.cloudhost.svc.cluster.local:5000 \
--docker-username=harbor_registry_user \
--docker-password="${REG_PASS}"
```
نمونهٔ manifest: [`gitops/jobs/kaniko-harbor-auth.example.yaml`](gitops/jobs/kaniko-harbor-auth.example.yaml) — نسخهٔ واقعی به‌صورت SealedSecret در ریپوی gitops است.
ورک‌فلو این Secret را در مسیر `/kaniko/.docker/config.json` هر دو Kaniko Job مانت می‌کند. چون push/pull داخلی و بدون TLS است، این فلگ‌ها لازم‌اند:
- `--insecure` / `--skip-tls-verify` — push
- `--insecure-pull` / `--insecure-registry=${PUSH_REGISTRY}` — pull ایمیج پایه از `harbor-registry:5000`
ایمیج پایه (`node:24-alpine`) از endpoint داخلی کشیده می‌شود، نه از `registry.abrban.com`:
```yaml
# در .gitea/workflows/build-deploy.yaml
--build-arg=BASE_IMAGE=harbor-registry.cloudhost.svc.cluster.local:5000/abrban/node:24-alpine
```
Dockerfileها از `ARG BASE_IMAGE=node:24-alpine` استفاده می‌کنند (build محلی بدون تغییر).
> **پروکسی egress** (`registry-egress-proxy` در ns `cloudhost-builds`) هنوز لازم است برای `npm ci` و دانلود helm/kubectl **داخل** مراحل RUN در Dockerfile — فقط pull ایمیج پایه از docker.io حذف شده است.
> **عارضهٔ جانبی push مستقیم به :5000** — Harbor DB از این ایمیج‌ها بی‌خبر می‌ماند؛ در UI هاربر دیده نمی‌شوند ولی pull به‌درستی کار می‌کند. برای دیدن تگ‌ها از registry API استفاده کنید (بخش عیب‌یابی).
### ارتباط Runner با Harbor
Runner خودش با Harbor حرف نمی‌زند؛ فقط Job می‌سازد. دو مسیر Harbor:
- **Push (داخلی):** `harbor-registry.cloudhost.svc.cluster.local:5000` — بدون عبور از Traefik.
- **Pull (kubelet):** `registry.abrban.com` — از طریق mirror در k3s (`scripts/apply-k3s-registries.sh`) به harbor-core route می‌شود.
---
## Versioning ایمیج‌ها
**استاندارد فعلی:** `YYYYMMDD-HHMM-<git-sha-short>` (مثلاً `20260702-1230-a1b2c3d`)
- **Immutable** است — هیچ‌وقت یک تگ بازنویسی نمی‌شود (برخلاف `latest`).
- **قابل ردیابی** است — از روی تگ ایمیجِ در حال اجرا مستقیماً به کامیت می‌رسید.
- **مرتب‌شونده** است — به‌ترتیب زمانی دیده می‌شود.
از `latest` هرگز برای Deploy استفاده نکنید؛ هم قابلیت Rollback را از بین می‌برد و هم Argo CD تغییری برای sync نمی‌بیند.
**SemVer برای Releaseها (اختیاری):** روی کامیت release یک Git Tag مثل `v1.4.0` بزنید و همان ایمیج را با `skopeo copy` تگ اضافه بزنید (rebuild لازم نیست). تگ SemVer برای انسان‌هاست؛ منبع حقیقتِ Deploy همان تگ SHA-دار در values است.
---
## آپدیت خودکار Helm Values
مرحلهٔ آخر Workflow ریپوی `cloud-host-gitops` را کلون می‌کند و فقط دو مقدار را در `platform/values-abrban.yaml` عوض می‌کند:
```yaml
images:
backend:
repository: registry.abrban.com/abrban/cloudhost-backend
tag: "20260702-1230-a1b2c3d" # ← CI این را آپدیت می‌کند
frontend:
repository: registry.abrban.com/abrban/cloudhost-frontend
tag: "20260702-1230-a1b2c3d" # ← CI این را آپدیت می‌کند
```
اگر `yq` روی Runner موجود باشد از آن استفاده می‌شود، وگرنه `sed` هدفمند (فقط خطِ `tag:` بلافاصله بعد از `repository: ...cloudhost-*`) اجرا می‌شود.
جایگزین بررسی‌شده و کنارگذاشته‌شده: **Argo CD Image Updater** — با روش فعلی هم‌پوشانی دارد و شفافیت کامیتِ صریح از CI را ندارد.
---
## Rollback
چون Deploy فقط از Git انجام می‌شود، Rollback هم یک عملیات Git است — این بار در ریپوی `cloud-host-gitops`:
```bash
git clone https://git.abrban.com/abrban/cloud-host-gitops.git && cd cloud-host-gitops
# 1. پیدا کردن کامیت deploy مشکل‌دار
git log --oneline -- platform/values-abrban.yaml
# 2. برگرداندن آن (تگ ایمیج به نسخهٔ قبلی برمی‌گردد)
git revert <commit-sha>
git push origin main
# 3. Argo CD به‌صورت خودکار به نسخهٔ قبلی sync می‌کند (ایمیج قبلی هنوز در Harbor هست)
```
نکته‌ها:
- `git revert` (نه `reset --force`) — history حفظ می‌شود و مشخص است چه چیزی چرا برگشت.
- **Rollback اضطراری** (وقتی Git در دسترس نیست): `argocd app rollback abrban-platform` یا Sync به revision قبلی در UI. **هشدار:** چون `selfHeal: true` فعال است، Argo در sync بعدی دوباره به HEAD گیت برمی‌گردد — rollback اضطراری موقتی است و باید بلافاصله با `git revert` دائمی شود.
- اگر Deployment جدید خراب باشد (CrashLoopBackOff)، به‌خاطر `RollingUpdate` نسخهٔ قبلی تا آماده‌شدن نسخهٔ جدید بالا می‌ماند.
---
## مدیریت Secretها (Sealed Secrets)
کنترلر **Sealed Secrets** در `kube-system` نصب است (values در [`gitops/sealed-secrets/values.yaml`](gitops/sealed-secrets/values.yaml)؛ ایمیج آن از `ghcr.io/bitnami` به پروژهٔ `abrban/` هاربر seed شده). Secretهای CI به‌صورت **SealedSecret** در ریپوی `cloud-host-gitops` (پوشهٔ `sealed-secrets/`) نگهداری می‌شوند — رمزشده با کلید عمومی کلاستر؛ فقط کنترلرِ داخل کلاستر می‌تواند رمزگشایی کند، پس کامیت‌کردنشان امن است.
| SealedSecret | Namespace | محتوا |
|--------------|-----------|-------|
| `gitea-act-runner-token` | `gitea` | توکن ثبت Runner |
| `kaniko-harbor-auth` | `cloudhost-builds` | dockerconfig کاربر `harbor_registry_user` |
| `gitea-gitops-repo-creds` | `argocd` | repo credential ریپوی gitops (کاربر `ci`) |
| `abrban-platform-secrets` | `cloudhost` | postgres-password، jwt-secret، jwt-refresh-secret، **cluster-kubeconfig-key**، **redis-password** |
| `elasticsearch-credentials` | `logging` | ELASTIC_PASSWORD، FLUENTBIT_PASSWORD (خارج از چارت پلتفرم — [`elasticsearch-credentials.example.yaml`](gitops/sealed-secrets/elasticsearch-credentials.example.yaml)) |
چارت Helm با `secrets.existingSecret: abrban-platform-secrets` در `platform/values-abrban.yaml` (ریپوی gitops) از Secret ازپیش‌ساخته استفاده می‌کند — Argo CD با `helm template` نمی‌تواند Secret تصادفی بسازد (lookup خالی است و هر sync مقادیر JWT/Redis را عوض می‌کند).
نمونهٔ کامل values: [`gitops/platform/values-abrban.example.yaml`](gitops/platform/values-abrban.example.yaml) — شامل mirror ایمیج postgres/redis، `BASE_IMAGE_REGISTRY`، و envهای Elastic.
### Greenfield / ارتقا از نسخهٔ قدیم
اگر کلاستر قبلاً با schema یا namespace قدیمی بالا آمده، قبل از deploy جدید **reset دیتابیس** لازم است. مراحل کامل (با متغیرهای قابل‌تنظیم برای هر محیط) در **[`RUNBOOK-DEPLOY.fa.md` — فاز ۶](RUNBOOK-DEPLOY.fa.md#فاز-۶--greenfield--ارتقا-از-نسخهٔ-قدیم)**.
### ساخت/به‌روزرسانی یک SealedSecret
```bash
brew install kubeseal # فقط بار اول
kubectl -n <ns> create secret generic <name> --from-literal=key=value --dry-run=client -o json \
| kubeseal --controller-name=sealed-secrets-controller --controller-namespace=kube-system --format yaml \
> sealed-secrets/<name>.yaml
# سپس commit/push در ریپوی cloud-host-gitops و kubectl apply (یا sync توسط Argo در آینده)
```
> اگر Secret از قبل در کلاستر وجود دارد و می‌خواهید کنترلر آن را تصاحب کند، اول annotate کنید:
> `kubectl -n <ns> annotate secret <name> sealedsecrets.bitnami.com/managed="true"`
Secretهایی که هنوز دستی‌اند (خارج از چرخهٔ CI): `abrban-wildcard-tls`، `registry-pull-secret`، `registry-egress-proxy`، `harbor-core` (ساختهٔ Helm) — می‌توانند به‌تدریج seal شوند.
> **نکتهٔ امنیتی:** توکن ثبت Runner و پسورد پروکسی که قبلاً در history گیت افشا شده بودند rotate شده‌اند (توکن Runner جدید صادر و Runner دوباره ثبت شد). پسورد کاربر پروکسی (`builder`) روی سرور پروکسی هنوز باید توسط ادمین عوض شود؛ بعد از تغییر، Secret `registry-egress-proxy` را در namespaceهای `cloudhost` و `gitea` آپدیت کنید.
---
## Best Practiceهای GitOps در این استک (چک‌لیست)
- [x] **Git تنها منبع حقیقت** — Argo CD با `automated + prune + selfHeal`؛ تغییر دستی با `kubectl edit` برگردانده می‌شود.
- [x] **جداسازی App Repo از GitOps Repo** — history تمیز و دسترسی حداقلی CI.
- [x] **تگ Immutable به‌جای `latest`** — هر Build تگ یکتا دارد.
- [x] **جلوگیری از CI Loop** — کامیت CI به ریپوی جدا می‌رود که Workflow ندارد.
- [x] **Build بدون Docker Daemon** — Kaniko داخل Job، بدون `docker.sock` و بدون privileged.
- [x] **جداسازی push/pull هاربر** — push داخلی بدون عبور از Ingress؛ pull از طریق mirror k3s.
- [x] **Concurrency در Workflow** — دو push پشت‌سرهم روی آپدیت values با هم race نمی‌کنند.
- [x] **Secretهای GitOps-شده** — Sealed Secrets نصب و secretهای CI رمزشده در Git.
- [ ] **محیط Staging** — با `platform/values-staging.yaml` و Application دوم قابل اضافه‌شدن است.
- [ ] **Notification** — Argo CD Notifications برای اطلاع از Sync موفق/ناموفق.
---
## عیب‌یابی سریع
| علامت | بررسی |
|-------|-------|
| Workflow اجرا نمی‌شود | `kubectl -n gitea logs deploy/gitea-act-runner` — ثبت Runner و لیبل `abrban-builder` |
| Build fail — clone | معتبربودن Secret `CI_TOKEN` در تنظیمات ریپوی `cloud-host` |
| Build fail — pull ایمیج پایه | `node:24-alpine` باید seed شده باشد؛ Workflow باید `BASE_IMAGE=harbor-registry.../abrban/node:24-alpine` + `--insecure-pull` داشته باشد؛ نه pull مستقیم از docker.io |
| Build fail — UNAUTHORIZED روی registry.abrban.com | BASE_IMAGE نباید `registry.abrban.com/...` باشد — credential کانیکو فقط برای `harbor-registry:5000` است |
| Build fail — HTTP response to HTTPS client | `--insecure-pull` و `--insecure-registry=harbor-registry.cloudhost.svc.cluster.local:5000` در Kaniko args |
| Build fail — timeout npm/helm/kubectl | `registry-egress-proxy` در ns `cloudhost-builds` و سلامت پروکسی egress |
| Build fail — push به Harbor | `kubectl -n cloudhost-builds get secret kaniko-harbor-auth`؛ پسورد باید با `REGISTRY_CREDENTIAL_PASSWORD` هاربر یکی باشد |
| کامیت values push نمی‌شود | دسترسی write کاربر `ci` روی `cloud-host-gitops` |
| Argo sync نمی‌کند | `kubectl -n argocd get app abrban-platform`؛ هر دو repo credential (`gitea-repo-creds` و `gitea-gitops-repo-creds`) |
| Pod ایمیج را pull نمی‌کند | `registry-pull-secret` در ns `cloudhost` و mirror k3s (`scripts/apply-k3s-registries.sh`) |
| دیدن تگ‌های موجود در registry | از داخل کلاستر: `wget -qO- "http://harbor_registry_user:<REG_PASS>@harbor-registry.cloudhost.svc.cluster.local:5000/v2/abrban/cloudhost-backend/tags/list"` |
| Backend CrashLoop — CLUSTER_KUBECONFIG_KEY | Secret `abrban-platform-secrets` باید کلید `cluster-kubeconfig-key` داشته باشد و در values: `secrets.existingSecret: abrban-platform-secrets` |
| Backend CrashLoop — DB auth | پسورد postgres در Secret با DB واقعی هم‌خوان باشد (`ALTER USER ... WITH PASSWORD` در صورت rotate شدن Secret) |
| Backend CrashLoop — Redis auth | Secret `abrban-platform-secrets` باید کلید `redis-password` داشته باشد؛ backend و Redis پلتفرم هر دو از آن استفاده می‌کنند |
| Backend CrashLoop — ELASTIC_PASSWORD | در production مقدار پیش‌فرض رد می‌شود — env در values-abrban.yaml باید رمز rotate‌شده داشته باشد |
| Workflow fail — tests | Job `test-be-*` در ns `cloudhost-builds``kubectl logs job/... -c test` |
| SealedSecret باز نمی‌شود | `kubectl get sealedsecrets -A` (ستون SYNCED) و لاگ `kubectl -n kube-system logs deploy/sealed-secrets-controller` |
+426
View File
@@ -0,0 +1,426 @@
# RUNBOOK — استقرار پلتفرم CloudHost از صفر
این سند **کارهایی را که روی سرور/کلاستر باید انجام دهید** مرحله‌به‌مرحله توضیح می‌دهد — از bootstrap زیرساخت تا اولین deploy موفق پس از hardening.
> **برای چه کسی است:** هر کسی که می‌خواهد CloudHost را روی یک کلاستر Kubernetes تازه (یا کلاستر دیگری غیر از abrban) بالا بیاورد.
>
> **چه چیزی اینجا نیست:** جزئیات معماری اپ → [`RUNBOOK.fa.md`](RUNBOOK.fa.md)؛ جزئیات pipeline CI → [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md).
---
## قبل از شروع — جدول متغیرها
همهٔ دستورات زیر از این متغیرها استفاده می‌کنند. **یک‌بار** آن‌ها را برای محیط خودتان پر کنید:
| متغیر | توضیح | مثال abrban | مثال محیط جدید |
|-------|--------|-------------|----------------|
| `PLATFORM_NS` | namespace پلتفرم | `cloudhost` | `cloudhost` |
| `BUILD_NS` | namespace بیلد Kaniko | `cloudhost-builds` | `cloudhost-builds` |
| `LOGGING_NS` | namespace Elasticsearch | `logging` | `logging` |
| `REGISTRY_HOST` | آدرس pull ایمیج (Ingress/registry عمومی) | `registry.abrban.com` | `registry.example.com` |
| `REGISTRY_PROJECT` | پروژه Harbor برای ایمیج‌های platform | `abrban` | `cloudhost` |
| `REGISTRY_PUSH` | endpoint داخلی push (بدون TLS) | `harbor-registry.cloudhost.svc.cluster.local:5000` | `registry.registry.svc:5000` |
| `GIT_HOST` | URL گیت (Gitea/GitHub) | `git.abrban.com` | `git.example.com` |
| `APP_REPO` | ریپوی کد + چارت | `abrban/cloud-host` | `org/cloud-host` |
| `GITOPS_REPO` | ریپوی state (values + sealed secrets) | `abrban/cloud-host-gitops` | `org/cloud-host-gitops` |
| `VALUES_FILE` | فایل values در gitops | `platform/values-abrban.yaml` | `platform/values-production.yaml` |
| `PLATFORM_SECRET` | Secret پلتفرم (JWT, DB, Redis, …) | `abrban-platform-secrets` | `cloudhost-platform-secrets` |
| `ARGO_APP` | نام Application در Argo CD | `abrban-platform` | `cloudhost-platform` |
| `DOMAIN_LANDING` | لندینگ | `abrban.com` | `example.com` |
| `DOMAIN_PANEL` | پنل | `panel.abrban.com` | `panel.example.com` |
| `DOMAIN_API` | API | `api.abrban.com` | `api.example.com` |
| `DOMAIN_APPS` | دامنهٔ اپ‌های کاربر | `apps.abrban.com` | `apps.example.com` |
| `STORAGE_CLASS` | StorageClass PVCها | `local-path` | `standard` |
| `INGRESS_CLASS` | Ingress controller | `traefik` | `nginx` |
```bash
# نمونه — قبل از اجرای دستورات export کنید:
export PLATFORM_NS=cloudhost
export BUILD_NS=cloudhost-builds
export LOGGING_NS=logging
export REGISTRY_HOST=registry.example.com
export REGISTRY_PROJECT=cloudhost
export REGISTRY_PUSH=harbor-registry.cloudhost.svc.cluster.local:5000
export GIT_HOST=git.example.com
export APP_REPO=org/cloud-host
export GITOPS_REPO=org/cloud-host-gitops
export VALUES_FILE=platform/values-production.yaml
export PLATFORM_SECRET=cloudhost-platform-secrets
export ARGO_APP=cloudhost-platform
export DOMAIN_LANDING=example.com
export DOMAIN_PANEL=panel.example.com
export DOMAIN_API=api.example.com
export DOMAIN_APPS=apps.example.com
export STORAGE_CLASS=standard
export INGRESS_CLASS=nginx
```
---
## دو مسیر استقرار
| | **مسیر A — GitOps (توصیه Production)** | **مسیر B — Helm مستقیم** |
|---|--------------------------------------|---------------------------|
| CI/CD | Gitea Actions → Kaniko → Argo CD | build/push دستی + `helm upgrade` |
| Values | ریپوی جدا `GITOPS_REPO` | فایل محلی `my-values.yaml` |
| Secretها | Sealed Secrets در gitops | inline در values یا Secret دستی |
| مستند | **همین سند +** [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md) | [`README.md`](README.md) بخش Deploy |
بقیهٔ این سند **مسیر A** را پوشش می‌دهد. برای مسیر B به انتهای سند بروید.
---
## مسیر A — GitOps: فاز ۰ تا ۷
### فاز ۰ — پیش‌نیازهای سخت‌افزاری و شبکه
- [ ] کلاستر Kubernetes (k3s یا دیگر) با kubectl از ماشین admin
- [ ] DNS: رکوردهای A/CNAME برای `$DOMAIN_LANDING`, `$DOMAIN_PANEL`, `$DOMAIN_API`, `$REGISTRY_HOST`, `$GIT_HOST`, Argo CD
- [ ] گواهی TLS (wildcard یا cert-manager + `clusterIssuer`)
- [ ] دسترسی `kubectl` به کلاستر
- [ ] `helm`, `kubeseal` (برای Sealed Secrets) روی ماشین admin
- [ ] دو ریپوی Git: `$APP_REPO` (کد) و `$GITOPS_REPO` (خالی یا با skeleton)
---
### فاز ۱ — Bootstrap زیرساخت (یک‌بار per cluster)
این مراحل در [`gitops/README.md`](gitops/README.md) هم هست؛ خلاصه:
```bash
cd cloud-host # ریپوی اپلیکیشن
# 1) mirror رجیستری k3s → Harbor (یا registry خودتان)
./scripts/apply-k3s-registries.sh # در صورت k3s؛ برای کلاستر دیگر mirror معادل تنظیم کنید
# 2) Argo CD
helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
-f gitops/argocd/values-bootstrap.yaml --timeout 15m --wait
# 3) Gitea (یا GitHub/GitLab — workflow را متناسب تنظیم کنید)
helm upgrade --install gitea gitea-charts/gitea -n gitea --create-namespace \
-f gitops/gitea/values.yaml --timeout 15m --wait
# 4) Secretهای TLS + registry-pull + egress در nsهای لازم
# (wildcard TLS و registry-pull-secret را یک‌بار در $PLATFORM_NS بسازید، سپس کپی)
for ns in argocd gitea $BUILD_NS; do
kubectl -n $PLATFORM_NS get secret <wildcard-tls-secret> -o yaml \
| sed "s/namespace: ${PLATFORM_NS}/namespace: ${ns}/" | kubectl apply -f -
kubectl -n $PLATFORM_NS get secret registry-pull-secret -o yaml \
| sed "s/namespace: ${PLATFORM_NS}/namespace: ${ns}/" | kubectl apply -f -
done
# 5) Seed ایمیج‌های CI (act-runner, alpine-git, node, kaniko) — فایل را برای REGISTRY_* خودتان ویرایش کنید
kubectl apply -f gitops/jobs/seed-ci-images.yaml
kubectl -n $PLATFORM_NS wait --for=condition=complete job/seed-ci-images --timeout=15m
# 6) Sealed Secrets controller
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \
-n kube-system -f gitops/sealed-secrets/values.yaml --timeout 10m --wait
# 7) Gitea Actions runner + Secret CI_TOKEN در ریپوی app
kubectl apply -f gitops/gitea/act-runner.yaml
# در Gitea: Settings → Actions → Secrets → CI_TOKEN = PAT کاربر ci
# 8) Argo CD Application (chart از app repo، values از gitops repo)
# قبل از apply: repoURLها در gitops/argocd/application-platform.yaml را با GIT_HOST/APP_REPO/GITOPS_REPO هم‌خوان کنید
kubectl apply -f gitops/argocd/application-platform.yaml
```
**بررسی فاز ۱:**
```bash
kubectl get nodes
kubectl -n argocd get pods
kubectl -n gitea get pods
kubectl -n kube-system get pods -l app.kubernetes.io/name=sealed-secrets
```
---
### فاز ۲ — آماده‌سازی ریپوی GitOps (values)
```bash
# کلون ریپوی gitops (کنار ریپوی app یا هر مسیر دلخواه)
git clone "https://${GIT_HOST}/${GITOPS_REPO}.git" cloud-host-gitops
cd cloud-host-gitops
# کپی template values از ریپوی app
cp ../cloud-host/gitops/platform/values-abrban.example.yaml "${VALUES_FILE}"
```
**فایل values را برای محیط خودتان ویرایش کنید** — حداقل این فیلدها:
| بخش | چه چیزی عوض شود |
|-----|------------------|
| `images.postgres/redis/busybox` | مسیر mirror در `$REGISTRY_HOST` (مثلاً `proxy-dockerhub/library/postgres:16-alpine`) |
| `images.backend/frontend.repository` | `$REGISTRY_HOST/$REGISTRY_PROJECT/cloudhost-backend` |
| `secrets.existingSecret` | `$PLATFORM_SECRET` |
| `ingress.*.host` | `$DOMAIN_LANDING`, `$DOMAIN_PANEL`, `$DOMAIN_API` |
| `ingress.className` | `$INGRESS_CLASS` |
| `global.storageClass` | `$STORAGE_CLASS` |
| `backend.env.PLATFORM_DOMAIN` | `$DOMAIN_APPS` |
| `backend.env.FRONTEND_URL` | `https://${DOMAIN_PANEL},https://${DOMAIN_LANDING}` |
| `backend.env.REGISTRY_URL` | push داخلی: `$REGISTRY_PUSH/$REGISTRY_PROJECT` |
| `backend.env.REGISTRY_PULL_URL` | `$REGISTRY_HOST/$REGISTRY_PROJECT` |
| `backend.env.BASE_IMAGE_REGISTRY` | prefix mirror برای Dockerfileهای کاربر |
| `backend.env.ELASTIC_*` | بعد از فاز ۴ پر می‌شود |
| `postgres/redis.imagePullSecrets` | `[{ name: registry-pull-secret }]` |
```bash
git add "${VALUES_FILE}"
git commit -m "chore: initial platform values for $(hostname -s 2>/dev/null || echo production)"
git push origin main
```
> **نکته:** CI فقط `images.backend.tag` و `images.frontend.tag` را عوض می‌کند — بقیهٔ فایل دست شماست.
---
### فاز ۳ — Secretهای پلتفرم (Sealed Secrets)
Secret پلتفرم **نباید** در values به‌صورت plaintext commit شود. از SealedSecret استفاده کنید.
**کلیدهای الزامی** در `$PLATFORM_SECRET`:
| کلید | کاربرد |
|------|--------|
| `postgres-password` | Postgres پلتفرم + migration Job |
| `jwt-secret` | JWT access (حداقل ۳۲ کاراکتر تصادفی) |
| `jwt-refresh-secret` | JWT refresh |
| `cluster-kubeconfig-key` | رمزگذاری kubeconfig کلاسترها (۶۴ hex یا passphrase قوی) |
| `redis-password` | Redis پلتفرم + backend (Bull queues) |
```bash
cd cloud-host-gitops
# تولید رمزهای تصادفی (یا خودتان مقدار قوی بگذارید)
PG_PASS="$(openssl rand -base64 24)"
JWT="$(openssl rand -base64 32)"
JWT_REFRESH="$(openssl rand -base64 32)"
KUBE_KEY="$(openssl rand -hex 32)"
REDIS_PASS="$(openssl rand -base64 24)"
kubectl -n $PLATFORM_NS create secret generic "$PLATFORM_SECRET" \
--from-literal=postgres-password="$PG_PASS" \
--from-literal=jwt-secret="$JWT" \
--from-literal=jwt-refresh-secret="$JWT_REFRESH" \
--from-literal=cluster-kubeconfig-key="$KUBE_KEY" \
--from-literal=redis-password="$REDIS_PASS" \
--dry-run=client -o json \
| kubeseal \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
> "sealed-secrets/${PLATFORM_SECRET}.yaml"
kubectl apply -f "sealed-secrets/${PLATFORM_SECRET}.yaml"
git add "sealed-secrets/${PLATFORM_SECRET}.yaml"
git commit -m "chore: seal platform secrets"
git push origin main
```
**بررسی:**
```bash
kubectl -n $PLATFORM_NS get secret "$PLATFORM_SECRET"
kubectl get sealedsecrets -A | grep "$PLATFORM_SECRET"
```
SealedSecretهای CI دیگر (kaniko، runner، repo creds) را طبق [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md) بسازید.
---
### فاز ۴ — Logging stack + Secret Elasticsearch
```bash
cd cloud-host
# 1) namespace logging (اگر در manifest نیست)
kubectl create namespace $LOGGING_NS --dry-run=client -o yaml | kubectl apply -f -
# 2) Secret elasticsearch — خارج از git (plaintext commit ممنوع)
ELASTIC_PASS="$(openssl rand -base64 24)"
FLUENT_PASS="$(openssl rand -base64 24)"
kubectl -n $LOGGING_NS create secret generic elasticsearch-credentials \
--from-literal=ELASTIC_PASSWORD="$ELASTIC_PASS" \
--from-literal=FLUENTBIT_PASSWORD="$FLUENT_PASS"
# یا seal کنید:
kubectl -n $LOGGING_NS create secret generic elasticsearch-credentials \
--from-literal=ELASTIC_PASSWORD="$ELASTIC_PASS" \
--from-literal=FLUENTBIT_PASSWORD="$FLUENT_PASS" \
--dry-run=client -o json \
| kubeseal --controller-name=sealed-secrets-controller \
--controller-namespace=kube-system --format yaml \
> ../cloud-host-gitops/sealed-secrets/elasticsearch-credentials.yaml
# 3) deploy stack (بدون Secret inline — manifest فقط ConfigMap/Deployment دارد)
kubectl apply -f backend/k8s/logging/elasticsearch-stack.yaml
# 4) همان مقادیر را در values پلتفرم بگذارید (backend.env)
# ELASTIC_PASSWORD, FLUENTBIT_PASSWORD, KIBANA_SYSTEM_PASSWORD
# سپس commit/push در gitops repo
```
> backend در production بدون `ELASTIC_PASSWORD` معتبر **بالا نمی‌آید** (`validate-production-config`).
---
### فاز ۵ — اولین Deploy
**روش ۱ — CI (توصیه):** push به `main` در `$APP_REPO` → workflow تست + Kaniko + آپدیت tag در gitops → Argo sync.
```bash
cd cloud-host
git push origin main # یا push به Gitea remote
# پیگیری: Gitea Actions UI یا kubectl -n $BUILD_NS get jobs -w
```
**روش ۲ — دستی (bootstrap / بدون CI):**
```bash
# build ایمیج‌ها (روی ماشینی که به registry دسترسی دارد) یا trigger-platform-build.sh
TAG="$(date +%Y%m%d-%H%M)-manual"
VALUES="../cloud-host-gitops/${VALUES_FILE}"
./scripts/gitops-deploy.sh TAG="$TAG" VALUES="$VALUES"
```
**بررسی Argo:**
```bash
kubectl -n argocd get app "$ARGO_APP"
argocd app sync "$ARGO_APP" # در صورت sync خودکار غیرفعال
kubectl -n $PLATFORM_NS get pods
kubectl -n $PLATFORM_NS rollout status deploy/cloudhost-backend --timeout=300s
kubectl -n $PLATFORM_NS rollout status deploy/cloudhost-frontend --timeout=300s
```
---
### فاز ۶ — Greenfield / ارتقا از نسخهٔ قدیم
اگر کلاستر **قبلاً** با نسخهٔ قدیمی CloudHost بالا آمده (namespace کوتاه UUID، migration بدون `schema_migrations`**قبل از deploy جدید** دیتابیس را reset کنید.
> ⚠️ **فقط greenfield / بدون دادهٔ واقعی.** در production با داده، اول backup بگیرید.
```bash
# 1) backend را متوقف کنید
kubectl -n $PLATFORM_NS scale deploy/cloudhost-backend --replicas=0
# 2) schema را از نو بسازید
kubectl -n $PLATFORM_NS exec deploy/cloudhost-postgres -- \
psql -U cloudhost -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
# 3) Argo sync — migration Job (pre-upgrade hook) base schema + migrations را اجرا می‌کند
argocd app sync "$ARGO_APP"
# 4) backend را بالا بیاورید
kubectl -n $PLATFORM_NS scale deploy/cloudhost-backend --replicas=1
```
**تغییرات breaking که reset می‌خواهند:**
| تغییر | اثر |
|-------|-----|
| namespace کاربر `user-<uuid-32>` به‌جای `user-<8char>` | namespaceهای قدیمی دیگر استفاده نمی‌شوند — اپ‌ها redeploy |
| `000_base_schema.sql` + `schema_migrations` | DB باید از نو migrate شود |
| `redis-password` جدید | Secret + restart Redis و backend |
---
### فاز ۷ — چک‌لیست تأیید سلامت
```bash
# Podها
kubectl -n $PLATFORM_NS get deploy,pods
kubectl -n $LOGGING_NS get pods
# API
curl -sf "https://${DOMAIN_API}/api/v1/health" && echo OK
curl -sf "https://${DOMAIN_API}/api/v1/ready" && echo OK
# Frontend
curl -sf -o /dev/null -w '%{http_code}\n' "https://${DOMAIN_LANDING}"
curl -sf -o /dev/null -w '%{http_code}\n' "https://${DOMAIN_PANEL}"
# Migration
kubectl -n $PLATFORM_NS logs job/$(kubectl -n $PLATFORM_NS get jobs -o name | grep migration | tail -1 | cut -d/ -f2) 2>/dev/null || true
# Redis auth
kubectl -n $PLATFORM_NS exec deploy/cloudhost-redis -- redis-cli ping
# Backup CronJob (اگر enabled)
kubectl -n $PLATFORM_NS get cronjobs
```
| علامت | اقدام |
|-------|-------|
| Backend CrashLoop — JWT/DB/Redis | Secret `$PLATFORM_SECRET` و keys — [`RUNBOOK-CICD.fa.md`](RUNBOOK-CICD.fa.md) عیب‌یابی |
| Backend CrashLoop — ELASTIC_PASSWORD | env در values + Secret logging |
| Migration fail | `kubectl logs` روی migration Job؛ `schema_migrations` و فایل‌های `backend/migrations/` |
| Argo OutOfSync | `argocd app diff $ARGO_APP` |
---
## مسیر B — Helm مستقیم (بدون GitOps)
برای lab، staging، یا کلاستری **بدون** Gitea/Argo:
```bash
cp backend/helm/cloudhost-platform/values-production.example.yaml my-values.yaml
# ویرایش: hosts, registry, secrets (jwtSecret, postgres.password, redis.password), ingress
docker build -t $REG/cloudhost-backend:1.0.0 ./backend
docker build -t $REG/cloudhost-frontend:1.0.0 \
--build-arg NEXT_PUBLIC_API_URL=https://${DOMAIN_API} ./frontend
docker push $REG/cloudhost-backend:1.0.0
docker push $REG/cloudhost-frontend:1.0.0
helm upgrade --install cloudhost ./backend/helm/cloudhost-platform \
-n $PLATFORM_NS --create-namespace \
-f my-values.yaml \
--set images.backend.repository=$REG/cloudhost-backend \
--set images.frontend.repository=$REG/cloudhost-frontend \
--set images.backend.tag=1.0.0 \
--set images.frontend.tag=1.0.0 \
--set global.storageClass=$STORAGE_CLASS
```
> در این مسیر `secrets.existingSecret` خالی بماند تا Helm Secret بسازد — **برای production با Argo CD توصیه نمی‌شود** (lookup در `helm template` خالی است).
---
## فایل‌های مرجع در ریپو
| فایل | نقش |
|------|-----|
| [`gitops/platform/values-abrban.example.yaml`](gitops/platform/values-abrban.example.yaml) | Template values — کپی و rename برای محیط جدید |
| [`gitops/sealed-secrets/abrban-platform-secrets.example.yaml`](gitops/sealed-secrets/abrban-platform-secrets.example.yaml) | دستور seal Secret پلتفرم |
| [`gitops/sealed-secrets/elasticsearch-credentials.example.yaml`](gitops/sealed-secrets/elasticsearch-credentials.example.yaml) | دستور seal Secret logging |
| [`backend/helm/cloudhost-platform/values-production.example.yaml`](backend/helm/cloudhost-platform/values-production.example.yaml) | Template برای مسیر B |
| [`scripts/gitops-deploy.sh`](scripts/gitops-deploy.sh) | deploy دستی با Helm + values از gitops |
---
## خلاصهٔ ترتیب (Quick reference)
```
فاز ۰ DNS + kubectl + helm + kubeseal + دو ریپو
فاز ۱ Argo + Gitea + registry + sealed-secrets + runner + Application
فاز ۲ کپی values template → ویرایش → push gitops
فاز ۳ seal platform secrets → push gitops
فاز ۴ elasticsearch stack + secret + env در values
فاز ۵ push main (CI) یا gitops-deploy.sh (دستی)
فاز ۶ (در صورت upgrade) reset DB
فاز ۷ health check
```
+2
View File
@@ -8,6 +8,8 @@
> **به‌روزرسانی ۲۰۲۶:** pipeline بیلد فعلی از **Kaniko** + Dockerfileهای نگهداری‌شده توسط پلتفرم استفاده می‌کند (نه Nixpacks/MinIO). آرشیو سورس روی دیسک/PVC آپلود می‌شود. manifest بوت‌استرپ namespace بیلد: [`backend/k8s/builds/cloudhost-builds-bootstrap.yaml`](backend/k8s/builds/cloudhost-builds-bootstrap.yaml).
> **استقرار از صفر روی سرور:** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — مراحل values، Secretها، deploy، greenfield reset.
---
### ۱.۱ CloudHost چیست
+5
View File
@@ -75,6 +75,11 @@ REGISTRY_PASSWORD=registry_secret
# Build
BUILD_NAMESPACE=cloudhost-builds
BUILD_SERVICE_ACCOUNT=kaniko-builder
# Kaniko job images — defaults pull from Harbor proxy-cache when unset.
# KANIKO_IMAGE=registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2
# BUILD_ALPINE_IMAGE=registry.abrban.com/proxy-dockerhub/library/alpine:3.19
# BUILD_ALPINE_GIT_IMAGE=registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0
# BASE_IMAGE_REGISTRY=registry.abrban.com/proxy-dockerhub/library
# Platform
# Public URL(s) of the frontend — used for CORS and to derive the platform/preview
+3 -2
View File
@@ -1,5 +1,6 @@
# ---- Stage 1: Build ----
FROM node:24-alpine AS builder
ARG BASE_IMAGE=node:24-alpine
FROM ${BASE_IMAGE} AS builder
WORKDIR /app
@@ -10,7 +11,7 @@ COPY . .
RUN npm run build
# ---- Stage 2: Production ----
FROM node:24-alpine AS production
FROM ${BASE_IMAGE} AS production
RUN apk add --no-cache dumb-init curl bash \
&& curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz | tar xz -C /tmp \
@@ -46,6 +46,16 @@ Database deployment name
{{- printf "%s-db" .Values.app.name }}
{{- 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
*/}}
@@ -53,13 +63,13 @@ Database image — auto-computed from type + version if not explicitly set
{{- if .Values.database.image }}
{{- .Values.database.image }}
{{- 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" }}
{{- 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" }}
{{- printf "mongo:%s" .Values.database.version }}
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mongo:%s" .Values.database.version)) }}
{{- else }}
{{- printf "mysql:%s" .Values.database.version }}
{{- include "cloudhost-app.baseImage" (dict "root" $ "image" (printf "mysql:%s" .Values.database.version)) }}
{{- end }}
{{- end }}
@@ -5,7 +5,7 @@
{{- define "cloudhost-app.logShipperContainers" -}}
{{- if .root.Values.elasticsearch.enabled }}
- name: log-shipper
image: fluent/fluent-bit:2.2
image: {{ include "cloudhost-app.baseImage" (dict "root" .root "image" "fluent/fluent-bit:2.2") }}
resources:
requests:
cpu: "10m"
@@ -16,6 +16,10 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
spec:
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:
matchLabels:
app: {{ $dbName }}
@@ -114,7 +118,7 @@ spec:
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
{{- else if eq .Values.database.type "mongodb" }}
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 }}
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
@@ -131,7 +135,7 @@ spec:
command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
{{- else if eq .Values.database.type "mongodb" }}
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 }}
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
@@ -217,7 +217,7 @@ spec:
{{- end }}
{{- if .Values.elasticsearch.enabled }}
- name: fluent-bit
image: fluent/fluent-bit:2.2
image: {{ include "cloudhost-app.baseImage" (dict "root" $ "image" "fluent/fluent-bit:2.2") }}
resources:
requests:
cpu: "10m"
@@ -1,4 +1,7 @@
{{- 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
kind: Secret
metadata:
@@ -8,7 +11,7 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
type: Opaque
stringData:
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | default "CloudHost2024!Secure" | quote }}
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | default "FluentBit2024!Writer" | quote }}
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | default "Kibana2024!System" | quote }}
ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | quote }}
FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | quote }}
KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | quote }}
{{- end }}
@@ -2,11 +2,22 @@
{{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}}
{{- $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
kind: Secret
metadata:
name: {{ $rabbitName }}-secret
name: {{ $rabbitSecretName }}
namespace: {{ $ns }}
labels:
app: {{ $rabbitName }}
@@ -16,7 +27,7 @@ metadata:
type: Opaque
data:
username: {{ "appuser" | b64enc | quote }}
password: {{ randAlphaNum 16 | b64enc | quote }}
password: {{ $rabbitPass | b64enc | quote }}
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -48,6 +59,9 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
spec:
replicas: 1
# RWO volume + single replica: recreate instead of rolling update.
strategy:
type: Recreate
selector:
matchLabels:
app: {{ $rabbitName }}
@@ -58,7 +72,7 @@ spec:
spec:
containers:
- 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:
- containerPort: 5672
name: amqp
@@ -68,12 +82,12 @@ spec:
- name: RABBITMQ_DEFAULT_USER
valueFrom:
secretKeyRef:
name: {{ $rabbitName }}-secret
name: {{ $rabbitSecretName }}
key: username
- name: RABBITMQ_DEFAULT_PASS
valueFrom:
secretKeyRef:
name: {{ $rabbitName }}-secret
name: {{ $rabbitSecretName }}
key: password
volumeMounts:
- name: rabbitmq-data
@@ -2,11 +2,21 @@
{{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}}
{{- $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
kind: Secret
metadata:
name: {{ $redisName }}-secret
name: {{ $redisSecretName }}
namespace: {{ $ns }}
labels:
app: {{ $redisName }}
@@ -15,7 +25,7 @@ metadata:
"helm.sh/resource-policy": keep
type: Opaque
data:
password: {{ randAlphaNum 16 | b64enc | quote }}
password: {{ $redisPass | b64enc | quote }}
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -47,6 +57,10 @@ metadata:
{{- include "cloudhost-app.labels" . | nindent 4 }}
spec:
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:
matchLabels:
app: {{ $redisName }}
@@ -57,7 +71,7 @@ spec:
spec:
containers:
- 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)"]
ports:
- containerPort: 6379
@@ -65,7 +79,14 @@ spec:
- name: REDIS_PASSWORD
valueFrom:
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
volumeMounts:
- name: redis-data
+6
View File
@@ -101,3 +101,9 @@ changeCause: ""
# ── Registry (for imagePullSecret) ──────────────────────
registry:
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: ""
@@ -1,4 +1,3 @@
{{- $auth := printf "elastic:%s" .Values.elasticPassword | b64enc }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
@@ -74,22 +73,20 @@ spec:
- name: es-data
mountPath: /usr/share/elasticsearch/data
readinessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
httpHeaders:
- name: Authorization
value: Basic {{ $auth }}
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
httpHeaders:
- name: Authorization
value: Basic {{ $auth }}
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,20 @@
-- Temporary external access grants (Redis, RabbitMQ, database)
CREATE TYPE service_access_target AS ENUM (
DO $$ BEGIN
CREATE TYPE service_access_target AS ENUM (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TYPE service_access_grant_status AS ENUM (
DO $$ BEGIN
CREATE TYPE service_access_grant_status AS ENUM (
'active',
'expired',
'revoked'
);
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS service_access_grants (
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';
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
ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application';
@@ -46,8 +46,12 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "cloudhost-platform.secretName" -}}
{{- if .Values.secrets.existingSecret }}
{{- .Values.secrets.existingSecret }}
{{- else }}
{{- printf "%s-secrets" (include "cloudhost-platform.fullname" .) }}
{{- end }}
{{- end }}
{{- define "cloudhost-platform.tlsSecretName" -}}
{{- if .Values.ingress.tls.secretName }}
@@ -109,3 +113,18 @@ PLATFORM_DOMAIN / preview domain from the first entry only. The panel host
{{- define "cloudhost-platform.frontendImage" -}}
{{- printf "%s:%s" .Values.images.frontend.repository .Values.images.frontend.tag }}
{{- end }}
{{- define "cloudhost-platform.buildEnv" -}}
- name: KANIKO_IMAGE
value: {{ .Values.build.images.kaniko | quote }}
- name: BUILD_ALPINE_IMAGE
value: {{ .Values.build.images.alpine | quote }}
- name: BUILD_ALPINE_GIT_IMAGE
value: {{ .Values.build.images.alpineGit | quote }}
- name: BASE_IMAGE_REGISTRY
value: {{ .Values.build.baseImageRegistry | quote }}
{{- if .Values.build.egressProxySecret }}
- name: BUILD_EGRESS_PROXY_SECRET
value: {{ .Values.build.egressProxySecret | quote }}
{{- end }}
{{- end }}
@@ -9,8 +9,14 @@ metadata:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
spec:
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:
type: Recreate
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ include "cloudhost-platform.backend.fullname" . }}
@@ -72,6 +78,11 @@ spec:
value: {{ include "cloudhost-platform.redis.fullname" . }}
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: redis-password
- name: JWT_SECRET
valueFrom:
secretKeyRef:
@@ -82,8 +93,48 @@ spec:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: jwt-refresh-secret
- name: CLUSTER_KUBECONFIG_KEY
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: cluster-kubeconfig-key
- name: ELASTIC_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: elastic-password
- name: FRONTEND_URL
value: {{ include "cloudhost-platform.corsOrigins" . | quote }}
{{- if .Values.backend.sms.enabled }}
- name: SMS_PROVIDER
value: {{ .Values.backend.sms.provider | default "mizbansms" | quote }}
- name: MIZBANSMS_FROM
value: {{ .Values.backend.sms.from | default "5000467254" | quote }}
- name: MIZBANSMS_API
value: {{ .Values.backend.sms.api | default "2016" | quote }}
- name: MIZBANSMS_USERTYPE
value: {{ .Values.backend.sms.userType | default "2" | quote }}
- name: MIZBANSMS_USERNAME
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: mizbansms-username
- name: MIZBANSMS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: mizbansms-password
{{- end }}
{{- if .Values.registry.credentialsSecret }}
- name: REGISTRY_USERNAME
value: {{ .Values.registry.username | default "harbor_registry_user" | quote }}
- name: REGISTRY_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.registry.credentialsSecret | quote }}
key: {{ .Values.registry.credentialsPasswordKey | default "REGISTRY_CREDENTIAL_PASSWORD" | quote }}
{{- end }}
{{- include "cloudhost-platform.buildEnv" . | nindent 12 }}
{{- range $key, $val := .Values.backend.env }}
- name: {{ $key }}
value: {{ $val | quote }}
@@ -7,7 +7,9 @@ metadata:
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
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-delete-policy: before-hook-creation,hook-succeeded
spec:
@@ -47,9 +49,20 @@ spec:
- -c
- |
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
echo ">>> Applying $f"
psql -v ON_ERROR_STOP=1 -f "$f"
name=$(basename "$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
echo ">>> All migrations applied"
volumeMounts:
@@ -41,6 +41,8 @@ spec:
FILE="/backup/cloudhost-${STAMP}.sql.gz"
pg_dump | gzip > "$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:
- name: backup
mountPath: /backup
@@ -19,6 +19,10 @@ spec:
labels:
app: {{ include "cloudhost-platform.postgres.fullname" . }}
spec:
{{- with .Values.postgres.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: postgres
image: {{ .Values.images.postgres | quote }}
@@ -19,9 +19,26 @@ spec:
labels:
app: {{ include "cloudhost-platform.redis.fullname" . }}
spec:
{{- with .Values.redis.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: redis
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:
- containerPort: 6379
volumeMounts:
@@ -1,3 +1,10 @@
{{- if not .Values.secrets.existingSecret }}
{{/*
NOTE: lookup only works with `helm install/upgrade` (CLI). Argo CD renders with
`helm template` where lookup is always empty, so values would be regenerated on
every sync. For GitOps deployments set secrets.existingSecret and manage the
Secret out-of-band (e.g. SealedSecret in the gitops repo).
*/}}
{{- $existing := lookup "v1" "Secret" (include "cloudhost-platform.namespace" .) (include "cloudhost-platform.secretName" .) }}
{{- $pgPass := .Values.postgres.password }}
{{- if not $pgPass }}
@@ -11,6 +18,14 @@
{{- if not $jwtRefresh }}
{{- if $existing }}{{- $jwtRefresh = index $existing.data "jwt-refresh-secret" | b64dec }}{{- else }}{{- $jwtRefresh = randAlphaNum 32 }}{{- end }}
{{- end }}
{{- $kubeconfigKey := .Values.secrets.clusterKubeconfigKey }}
{{- 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 }}
{{- 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
kind: Secret
metadata:
@@ -23,3 +38,6 @@ stringData:
postgres-password: {{ $pgPass | quote }}
jwt-secret: {{ $jwt | quote }}
jwt-refresh-secret: {{ $jwtRefresh | quote }}
cluster-kubeconfig-key: {{ $kubeconfigKey | quote }}
redis-password: {{ $redisPass | quote }}
{{- end }}
@@ -8,6 +8,11 @@ global:
storageClass: local-path # k3s example
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:
repository: registry.example.com/cloudhost-backend
tag: "1.0.0"
@@ -17,8 +22,25 @@ images:
tag: "1.0.0"
pullPolicy: Always
# Build job images — override for clusters without Harbor proxy-cache.
build:
images:
kaniko: registry.example.com/proxy-gcr/kaniko-project/executor:v1.23.2
alpine: registry.example.com/proxy-dockerhub/library/alpine:3.19
alpineGit: registry.example.com/proxy-dockerhub/alpine/git:2.43.0
baseImageRegistry: registry.example.com/proxy-dockerhub/library
postgres:
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:
jwtSecret: "CHANGE_ME_LONG_JWT_SECRET"
@@ -44,6 +66,18 @@ backend:
PLATFORM_DOMAIN: apps.example.com
REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000
REGISTRY_PULL_URL: registry.cloudhost-builds.svc.cluster.local:5000
# 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:
enabled: true
backups:
postgres:
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
retentionDays: 7
+74 -5
View File
@@ -12,6 +12,9 @@ createNamespace: true
global:
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:
postgres: postgres:16-alpine
redis: redis:7-alpine
@@ -25,6 +28,26 @@ images:
tag: "1.0.0"
pullPolicy: IfNotPresent
# Kaniko push credentials — harbor_registry_user for harbor-registry:5000 (Harbor production).
registry:
credentialsSecret: ""
credentialsPasswordKey: REGISTRY_CREDENTIAL_PASSWORD
username: harbor_registry_user
# Kaniko job images — defaults pull from Harbor proxy-cache.
# Override any line for a different registry/tag.
build:
images:
# Seeded into abrban/ via gitops/jobs/seed-ci-images.yaml — avoid flaky proxy-gcr pulls.
kaniko: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
alpine: registry.abrban.com/abrban/alpine:3.19
alpineGit: registry.abrban.com/abrban/alpine-git:2.43.0
# Seeded base images (gitops/jobs/seed-ci-images.yaml) — proxy-dockerhub cache can be corrupt on first pull.
baseImageRegistry: registry.abrban.com/abrban
# Secret with HTTP_PROXY/HTTPS_PROXY for Kaniko build jobs (npm, apk, git clone).
# Set to registry-egress-proxy in production; leave empty when nodes have direct egress.
egressProxySecret: ""
postgres:
enabled: true
database: cloudhost
@@ -32,12 +55,31 @@ postgres:
# Leave empty to auto-generate on first install (stored in Secret)
password: ""
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:
enabled: true
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:
enabled: true
@@ -49,8 +91,21 @@ backend:
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
resources: {}
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
extraEnv: {}
# OTP SMS — credentials live in the platform Secret (mizbansms-username/password).
sms:
enabled: false
provider: mizbansms
from: "5000467254"
api: "2016"
userType: "2"
env:
NODE_ENV: production
PORT: "4000"
@@ -73,12 +128,25 @@ frontend:
replicas: 1
imagePullSecrets:
- 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)
secrets:
# Use a pre-created Secret instead of chart-managed one. Required for GitOps
# (Argo CD renders with `helm template`, so lookup/randAlphaNum regenerate on
# every sync). Secret must contain keys: postgres-password, jwt-secret,
# jwt-refresh-secret, cluster-kubeconfig-key, redis-password, elastic-password.
existingSecret: ""
jwtSecret: ""
jwtRefreshSecret: ""
# AES key for encrypting stored kubeconfigs (64 hex chars or any passphrase)
clusterKubeconfigKey: ""
ingress:
enabled: true
@@ -110,6 +178,7 @@ monitoring:
backups:
postgres:
enabled: false
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
retentionDays: 7
+20 -26
View File
@@ -9,18 +9,16 @@ metadata:
labels:
app.kubernetes.io/managed-by: cloudhost
---
# Elasticsearch credentials secret
apiVersion: v1
kind: Secret
metadata:
name: elasticsearch-credentials
namespace: logging
type: Opaque
stringData:
# Admin credentials - change in production!
ELASTIC_PASSWORD: "CloudHost2024!Secure"
# For Fluent Bit to send logs
FLUENTBIT_PASSWORD: "FluentBit2024!Writer"
# Elasticsearch credentials — managed OUT-OF-BAND, never committed to git.
# Create the Secret before applying this manifest (or use a SealedSecret in
# the GitOps repo):
#
# kubectl -n logging create secret generic elasticsearch-credentials \
# --from-literal=ELASTIC_PASSWORD="$(openssl rand -base64 24)" \
# --from-literal=FLUENTBIT_PASSWORD="$(openssl rand -base64 24)"
#
# The backend reads the same values from ELASTIC_PASSWORD / FLUENTBIT_PASSWORD
# env vars (see cloudhost-platform values: backend.extraEnv or an extra Secret).
---
# ConfigMap for Elasticsearch configuration
apiVersion: v1
@@ -183,24 +181,20 @@ spec:
- name: data
mountPath: /usr/share/elasticsearch/data
readinessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
scheme: HTTP
httpHeaders:
- name: Authorization
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /_cluster/health?local=true
port: 9200
scheme: HTTP
httpHeaders:
- name: Authorization
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
exec:
command:
- sh
- -c
- curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,20 @@
-- Temporary external access grants (Redis, RabbitMQ, database)
CREATE TYPE service_access_target AS ENUM (
DO $$ BEGIN
CREATE TYPE service_access_target AS ENUM (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TYPE service_access_grant_status AS ENUM (
DO $$ BEGIN
CREATE TYPE service_access_grant_status AS ENUM (
'active',
'expired',
'revoked'
);
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TABLE IF NOT EXISTS service_access_grants (
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';
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
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: {
host: configService.get('redis.host'),
port: configService.get('redis.port'),
password: configService.get('redis.password'),
},
}),
inject: [ConfigService],
@@ -387,6 +387,29 @@ export class ApplicationsController {
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)
await this.kubernetesService.updateResources(app, dto, workload);
@@ -22,6 +22,7 @@ import {
detectRuntimeFromArchive,
} from '../build/runtime-detector';
import { SourceStorageService } from '../storage/source-storage.service';
import { userIdSlug } from '../kubernetes/k8s-workload.util';
import * as os from 'os';
@Injectable()
@@ -67,6 +68,18 @@ export class ApplicationsService {
dto = normalizeCreateApplicationDto(dto);
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.
const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
const clusterId = allocation.cluster.id;
@@ -96,7 +109,7 @@ export class ApplicationsService {
const baseLabel = dto.name;
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);
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
@@ -16,6 +16,7 @@ import {
CustomDomainStatus,
ProductType,
} from '../../common/enums';
import { Exclude, Expose } from 'class-transformer';
import { User } from '../../users/entities/user.entity';
import { Deployment } from '../../deployments/entities/deployment.entity';
@@ -51,9 +52,16 @@ export class Application {
@Column({ nullable: true })
dbUsername: string;
/** Never expose raw DB password in API responses — use hasDbPassword for UI. */
@Exclude({ toPlainOnly: true })
@Column({ nullable: true })
dbPassword: string;
@Expose()
get hasDbPassword(): boolean {
return !!this.dbPassword;
}
@Column({ nullable: true, default: '1Gi' })
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
@@ -111,8 +119,19 @@ export class Application {
@Column({ nullable: true })
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 })
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 })
gitBranch: string; // Branch to clone (default: main)
@@ -14,7 +14,11 @@ import {
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
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 { ApplicationsService } from '../applications/applications.service';
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
@@ -43,8 +47,11 @@ export class BillingWalletController {
}
@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) {
// 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');
}
@@ -160,7 +167,7 @@ export class BillingWalletController {
@Body() body: { amount: number; description?: string; callbackUrl: string },
) {
assertStubGatewayAllowed();
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
const trackingCode = issueGatewayTrackingCode(req.user.id, body.amount);
return {
success: true,
trackingCode,
@@ -176,6 +183,8 @@ export class BillingWalletController {
@Body() body: { trackingCode: string; amount: number },
) {
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(
req.user.id,
body.amount,
+55 -14
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
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 { WalletTransaction } from './entities/wallet-transaction.entity';
import { Invoice } from './entities/invoice.entity';
@@ -172,6 +172,32 @@ export class BillingService {
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(
userId: string,
amount: number,
@@ -180,11 +206,12 @@ export class BillingService {
): Promise<WalletTransaction> {
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;
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,
type: TransactionType.CHARGE,
amount,
@@ -192,9 +219,10 @@ export class BillingService {
description: description || 'Wallet charge',
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;
}
@@ -207,15 +235,16 @@ export class BillingService {
): Promise<WalletTransaction> {
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) {
throw new BadRequestException('Insufficient wallet balance');
}
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,
type: TransactionType.DEDUCTION,
amount,
@@ -224,9 +253,10 @@ export class BillingService {
applicationId,
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;
}
@@ -743,7 +773,9 @@ export class BillingService {
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 remainingHours = 0;
@@ -752,9 +784,18 @@ export class BillingService {
const expiresAt = new Date(app.planExpiresAt);
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)
if (difference.hourly > 0) {
proratedAmount = Math.ceil(difference.hourly * remainingHours);
if (cycleDifference > 0) {
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.
@@ -12,3 +13,49 @@ export function assertStubGatewayAllowed(): void {
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';
const KEY_PREFIX = 'build:progress:';
const SESSION_KEY_PREFIX = 'build:session:';
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()
export class BuildProgressStore implements OnModuleDestroy {
private readonly redis: Redis;
@@ -14,6 +30,7 @@ export class BuildProgressStore implements OnModuleDestroy {
this.redis = new Redis({
host: this.configService.get<string>('redis.host'),
port: this.configService.get<number>('redis.port'),
password: this.configService.get<string>('redis.password'),
lazyConnect: true,
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 {
this.redis.disconnect();
}
+25
View File
@@ -137,4 +137,29 @@ describe('BuildService', () => {
expect(dockerfile).toContain('dotnet publish "$CSPROJ"');
});
});
describe('egressProxyEnvFrom', () => {
it('returns secretRef when BUILD_EGRESS_PROXY_SECRET is set', () => {
const config = (service as any).configService as { get: jest.Mock };
config.get.mockImplementation((key: string) => {
if (key === 'build.egressProxySecret') return 'registry-egress-proxy';
return undefined;
});
expect((service as any).egressProxyEnvFrom()).toEqual([
{ secretRef: { name: 'registry-egress-proxy' } },
]);
});
it('returns undefined when egress proxy is disabled', () => {
const config = (service as any).configService as { get: jest.Mock };
config.get.mockImplementation((key: string) => {
if (key === 'build.egressProxySecret') return '';
return undefined;
});
expect((service as any).egressProxyEnvFrom()).toBeUndefined();
expect((service as any).withEgressProxy({ name: 'kaniko' })).toEqual({ name: 'kaniko' });
});
});
});
+305 -98
View File
@@ -31,12 +31,14 @@ export class BuildCancelledError extends Error {
interface ActiveBuildSession {
cancelled: boolean;
applicationId?: string;
coreApi?: k8s.CoreV1Api;
batchApi?: k8s.BatchV1Api;
namespace?: string;
buildPodName?: string;
sourcePvcName?: string;
helperPodName?: string;
gitSecretName?: string;
processes: ChildProcess[];
socket?: net.Socket;
}
@@ -54,12 +56,6 @@ export class BuildService {
private readonly logger = new Logger(BuildService.name);
private readonly progressMap = new Map<string, BuildProgress>();
private readonly activeBuilds = new Map<string, ActiveBuildSession>();
/**
* Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node
* with imagePullPolicy=IfNotPresent avoids re-pulling the ~250MB image on every build.
*/
private readonly kanikoImage = process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
@@ -68,8 +64,99 @@ export class BuildService {
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}`;
}
/** Kaniko executor — pinned (not :latest) for node-level caching. */
private getKanikoImage(): string {
return this.configService.get<string>('build.images.kaniko')!;
}
/** Init/helper container image — explicit Harbor ref or baseImage() fallback. */
private resolveBuildImage(kind: 'alpine' | 'alpineGit', dockerHubFallback: string): string {
const explicit = this.configService.get<string>(`build.images.${kind}`);
if (explicit) return explicit;
return this.baseImage(dockerHubFallback);
}
/**
* Egress HTTP(S) proxy for build pods on restricted networks (Iran).
* Kaniko forwards these env vars into Dockerfile RUN steps (npm, apk, composer, pip).
*/
private egressProxyEnvFrom(): k8s.V1EnvFromSource[] | undefined {
const secretName = this.configService.get<string>('build.egressProxySecret');
if (!secretName?.trim()) return undefined;
return [{ secretRef: { name: secretName.trim() } }];
}
private withEgressProxy<T extends Record<string, unknown>>(container: T): T {
const envFrom = this.egressProxyEnvFrom();
if (!envFrom) return container;
return { ...container, envFrom };
}
/**
* 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 {
@@ -80,6 +167,26 @@ export class BuildService {
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
const session = this.activeBuilds.get(deploymentId);
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 {
@@ -122,7 +229,10 @@ export class BuildService {
}
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> {
@@ -154,7 +264,7 @@ export class BuildService {
}
}
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName, gitSecretName } = session;
if (coreApi && namespace) {
const cleanup: Promise<unknown>[] = [];
if (helperPodName) {
@@ -200,6 +310,11 @@ export class BuildService {
.catch(() => undefined),
);
}
if (gitSecretName) {
cleanup.push(
coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace }).catch(() => undefined),
);
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
}
@@ -209,7 +324,7 @@ export class BuildService {
percent: 0,
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). */
@@ -226,13 +341,14 @@ export class BuildService {
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.listNamespacedPersistentVolumeClaim({
namespace: buildNamespace,
}),
batchApi.listNamespacedJob({ namespace: buildNamespace }),
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
coreApi.listNamespacedSecret({ namespace: buildNamespace }),
]);
for (const pod of pods.items) {
@@ -283,6 +399,12 @@ export class BuildService {
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);
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
@@ -312,15 +434,15 @@ export class BuildService {
* Returns { imageUri, buildLog } the full image URI and the build logs.
*/
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
const registryUrl = this.registryService.getRegistryUrl();
const registryPushUrl = this.registryService.getRegistryPushUrl();
const buildNamespace = this.registryService.getBuildNamespace();
const tag = `${Date.now()}`;
const imageUri = this.registryService.buildImageReference(app.userId, app.name, tag);
const imageUri = this.registryService.buildPushImageReference(app.userId, app.name, tag);
this.logger.log(`Starting image build for ${app.name}${imageUri}`);
if (deploymentId) {
this.beginBuildSession(deploymentId);
this.beginBuildSession(deploymentId, app.id);
}
const hasUploadedCode = !!app.codePath;
@@ -389,6 +511,8 @@ export class BuildService {
// If we have uploaded code, create a PVC and upload via kubectl cp
let sourcePvcName: string | undefined;
// Secret holding the git token for private-repo clones (created lazily)
let gitSecretName: string | undefined;
if (hasUploadedCode && localZipPath) {
sourcePvcName = `${buildPodName}-source`;
if (deploymentId) {
@@ -406,11 +530,11 @@ export class BuildService {
// Build the Kaniko Job spec
// Always use dir context — init containers prepare /workspace/source
const kanikoArgs = [
'--dockerfile=/workspace/Dockerfile',
'--dockerfile=Dockerfile',
'--context=dir:///workspace/source',
`--destination=${imageUri}`,
'--cache=true',
`--cache-repo=${registryUrl}/${app.userId}/cache`,
`--cache-repo=${registryPushUrl}/${app.userId}/cache`,
'--insecure',
'--skip-tls-verify',
'--single-snapshot',
@@ -420,7 +544,10 @@ export class BuildService {
const volumes: any[] = [
{
name: 'docker-config',
secret: { secretName: 'registry-credentials' },
secret: {
secretName: 'registry-credentials',
items: [{ key: '.dockerconfigjson', path: 'config.json' }],
},
},
{
name: 'dockerfile',
@@ -444,27 +571,34 @@ export class BuildService {
});
// Add init container that unzips the source code from PVC
initContainers.push({
initContainers.push(this.withEgressProxy({
name: 'unzip-source',
image: 'alpine:3.19',
image: this.resolveBuildImage('alpine', 'alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: [
'sh',
'-c',
`
apk add --no-cache unzip tar gzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
reject_unsafe_path() {
case "$1" in ..|../*|*/../*|/*) echo "ERROR: unsafe archive path: $1" && exit 1;; esac
} &&
mkdir -p /tmp/extract &&
cd /tmp/extract &&
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected gzip tarball" &&
tar tzf /source-pvc/source.zip | while read -r entry; do reject_unsafe_path "$entry"; done &&
tar xzf /source-pvc/source.zip
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected zip archive" &&
unzip -Z1 /source-pvc/source.zip | while read -r entry; do reject_unsafe_path "$entry"; done &&
unzip -q /source-pvc/source.zip
else
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
fi &&
find /tmp/extract -mindepth 1 -print | while read -r path; do
case "$path" in /tmp/extract|/tmp/extract/*) ;; *) echo "ERROR: zip slip detected: $path" && exit 1;; esac
done &&
echo "--- Extracted contents ---" &&
ls -la /tmp/extract/ &&
mkdir -p /workspace-out/source &&
@@ -478,6 +612,7 @@ export class BuildService {
cp -a /tmp/extract/. /workspace-out/source/
fi &&
rm -rf /tmp/extract &&
cp /dockerfile/Dockerfile /workspace-out/source/Dockerfile &&
echo "--- Final workspace contents ---" &&
ls -la /workspace-out/source/
`,
@@ -486,43 +621,67 @@ export class BuildService {
{ name: 'workspace', mountPath: '/workspace-out' },
{
name: 'dockerfile',
mountPath: '/workspace/Dockerfile',
mountPath: '/dockerfile/Dockerfile',
subPath: 'Dockerfile',
},
{ name: 'source-pvc', mountPath: '/source-pvc' },
],
});
}));
} else if (hasGitUrl) {
// Build the git clone URL — inject token for private repos
let cloneUrl = app.gitUrl!;
// Validate user-controlled values before they get anywhere near a shell.
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) {
// Convert https://github.com/user/repo.git → https://<token>@github.com/user/repo.git
// Also works for GitLab, Bitbucket, etc.
try {
const url = new URL(cloneUrl);
url.username = app.gitToken;
url.password = ''; // Some providers use token as username, others as password
cloneUrl = url.toString();
} catch {
// If URL parsing fails, try simple injection after protocol
cloneUrl = cloneUrl.replace('https://', `https://${app.gitToken}@`);
gitSecretName = `${buildPodName}-git`;
if (deploymentId) this.updateBuildSession(deploymentId, { gitSecretName });
await coreApi.createNamespacedSecret({
namespace: buildNamespace!,
body: {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: gitSecretName, namespace: buildNamespace },
type: 'Opaque',
stringData: { GIT_TOKEN: app.gitToken },
},
});
}
}
const branch = app.gitBranch || 'main';
// Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({
initContainers.push(this.withEgressProxy({
name: 'git-clone',
image: 'alpine/git:2.43.0',
image: this.resolveBuildImage('alpineGit', 'alpine/git:2.43.0'),
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: [
'sh',
'-c',
`
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Workspace contents:" &&
set -e
if [ -n "\${GIT_TOKEN:-}" ]; then
printf '#!/bin/sh\\necho "$GIT_TOKEN"\\n' > /tmp/git-askpass.sh
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/source/Dockerfile
echo ">>> Workspace contents:"
ls -la /workspace-out/source/
`,
],
@@ -530,7 +689,7 @@ export class BuildService {
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' },
],
});
}));
}
// Kaniko container volume mounts
@@ -545,14 +704,14 @@ export class BuildService {
// add an init container that creates empty source dir + copies Dockerfile
initContainers.push({
name: 'prepare-workspace',
image: 'alpine:3.19',
image: this.resolveBuildImage('alpine', 'alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: [
'sh',
'-c',
`
mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
cp /dockerfile/Dockerfile /workspace-out/source/Dockerfile &&
echo ">>> Prepared empty workspace for fresh install" &&
ls -la /workspace-out/
`,
@@ -579,17 +738,23 @@ export class BuildService {
serviceAccountName: this.configService.get<string>('build.serviceAccount'),
initContainers: initContainers.length > 0 ? initContainers : undefined,
containers: [
{
this.withEgressProxy({
name: 'kaniko',
image: this.kanikoImage,
image: this.getKanikoImage(),
imagePullPolicy: 'IfNotPresent',
args: kanikoArgs,
volumeMounts: kanikoVolumeMounts,
resources: {
requests: { cpu: '500m', memory: '1Gi' },
limits: { cpu: '2', memory: '4Gi' },
requests: {
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',
},
},
}),
],
restartPolicy: 'Never',
volumes,
@@ -666,6 +831,17 @@ export class BuildService {
} catch (e: any) {
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);
cleanupSource?.();
}
@@ -780,6 +956,8 @@ export class BuildService {
metadata: { name: pvcName, namespace },
spec: {
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` } },
},
},
@@ -797,7 +975,7 @@ export class BuildService {
containers: [
{
name: 'helper',
image: 'alpine:3.19',
image: this.resolveBuildImage('alpine', 'alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'source', mountPath: '/data' }],
@@ -959,25 +1137,27 @@ export class BuildService {
}
}
// 3. Ensure registry-credentials secret (docker config for Kaniko to push)
// 3. Ensure registry-credentials secret (docker config for Kaniko push/pull)
const registrySecretName = 'registry-credentials';
const registrySecretBody = {
metadata: { name: registrySecretName, namespace },
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
},
};
try {
await coreApi.readNamespacedSecret({
await coreApi.replaceNamespacedSecret({
name: registrySecretName,
namespace,
body: registrySecretBody,
});
} catch (err: any) {
if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`);
await coreApi.createNamespacedSecret({
namespace,
body: {
metadata: { name: registrySecretName, namespace },
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
},
},
body: registrySecretBody,
});
} else {
throw err;
@@ -1012,10 +1192,12 @@ export class BuildService {
const port = app.port || 3000;
const nodeVersion = app.runtimeVersion || '20';
return `# --- Build stage ---
FROM node:${nodeVersion}-alpine AS builder
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder
WORKDIR /app
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 . .
# Auto-detect Next.js and enable standalone output
@@ -1031,13 +1213,19 @@ RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\
break; \\
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
RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true
# --- Production stage ---
FROM node:${nodeVersion}-alpine AS runner
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS runner
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
@@ -1070,9 +1258,9 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
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
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY --from=${this.baseImage('composer:2')} /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
@@ -1080,11 +1268,15 @@ COPY . .
RUN composer dump-autoload --optimize --no-dev --no-scripts
# --- Production stage ---
FROM php:${phpVersion}-fpm-alpine
FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
RUN apk add --no-cache nginx supervisor curl openssl \\
&& docker-php-ext-install pdo pdo_mysql opcache \\
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
# Laravel needs bcmath/gd/intl/zip beyond the built-in set; pdo_pgsql is built
# properly against libpq instead of being silently skipped.
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
COPY --from=composer /app .
@@ -1163,7 +1355,7 @@ CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"]
const phpVersion = app.phpVersion || '8.3';
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
RUN docker-php-ext-install opcache
@@ -1281,7 +1473,7 @@ CMD []`
const port = app.port || 8080;
const buildTarget = detectGoBuildTarget(archiveEntries);
return `# --- Build stage ---
FROM golang:${goVersion}-alpine AS builder
FROM ${this.baseImage(`golang:${goVersion}-alpine`)} AS builder
WORKDIR /app
# Install git for fetching dependencies
@@ -1297,8 +1489,13 @@ COPY . .
# Build the application
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 ---
FROM alpine:3.19
FROM ${this.baseImage('alpine:3.19')}
WORKDIR /app
# Add CA certificates for HTTPS requests
@@ -1307,11 +1504,9 @@ RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
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/static ./static 2>/dev/null || true
COPY --from=builder /app/templates ./templates 2>/dev/null || true
COPY --from=builder /app/public ./public 2>/dev/null || true
COPY --from=builder /assets/ ./
# Create data directory for persistent storage
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
@@ -1331,16 +1526,14 @@ CMD ["./main"]
private phpDockerfile(app: Application): string {
const phpVersion = app.phpVersion || '8.3';
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 \\
&& docker-php-ext-install pdo pdo_mysql opcache \\
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
# Install common PHP extensions
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\
# Install common PHP extensions (pdo_pgsql built properly against libpq)
RUN apk add --no-cache nginx supervisor curl postgresql-libs libpng libjpeg-turbo freetype \\
&& apk add --no-cache --virtual .build-deps postgresql-dev libpng-dev libjpeg-turbo-dev freetype-dev \\
&& 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
COPY . .
@@ -1401,7 +1594,7 @@ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app
# Install build dependencies
@@ -1409,13 +1602,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
pip install --no-cache-dir --user flask gunicorn
# Install dependencies from requirements.txt or pyproject.toml. A failing
# install FAILS the build no silent fallback that hides missing deps.
COPY . .
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 ---
FROM python:${pythonVersion}-slim
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app
# Install runtime dependencies
@@ -1455,21 +1655,28 @@ CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicor
const port = app.port || 8000;
const settingsModule = detectDjangoSettingsModule(archiveEntries);
return `# --- Build stage ---
FROM python:${pythonVersion}-slim AS builder
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app
# Install build dependencies
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/*
# Copy requirements and install dependencies
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient
# Install dependencies from requirements.txt or pyproject.toml. A failing
# install FAILS the build no silent fallback that hides missing deps.
COPY . .
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 ---
FROM python:${pythonVersion}-slim
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app
# Install runtime dependencies
+30
View File
@@ -82,6 +82,7 @@ export default () => ({
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
},
cluster: {
@@ -133,6 +134,35 @@ export default () => ({
build: {
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
/**
* Registry prefix for Docker Hub base images used in generated Dockerfiles
* and managed-service charts (e.g. `node:20-alpine`
* `registry.abrban.com/proxy-dockerhub/library/node:20-alpine`).
*/
baseImageRegistry: (process.env.BASE_IMAGE_REGISTRY || 'registry.abrban.com/abrban')
.trim()
.replace(/\/+$/, ''),
/** Full image refs for Kaniko jobs — override via Helm values or env. */
images: {
kaniko:
process.env.KANIKO_IMAGE ||
'registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug',
alpine: (process.env.BUILD_ALPINE_IMAGE || 'registry.abrban.com/abrban/alpine:3.19').trim(),
alpineGit: (process.env.BUILD_ALPINE_GIT_IMAGE || 'registry.abrban.com/abrban/alpine-git:2.43.0').trim(),
},
/**
* Secret name with HTTP_PROXY / HTTPS_PROXY / NO_PROXY for build pods
* (Kaniko RUN steps: npm, apk, composer, pip; init containers: apk, git clone).
* Empty = disabled (clusters with direct egress).
*/
egressProxySecret: (process.env.BUILD_EGRESS_PROXY_SECRET || '').trim(),
/** 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: {
@@ -28,12 +28,24 @@ describe('validateProductionConfig', () => {
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', () => {
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 = 'a-strong-rotated-elastic-password';
expect(() => validateProductionConfig()).not.toThrow();
});
@@ -25,6 +25,14 @@ export function validateProductionConfig(): void {
if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) {
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) {
throw new Error(
+83 -13
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 { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import * as fs from 'fs';
import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service';
@@ -9,6 +9,7 @@ import { BuildService, BuildProgress, BuildCancelledError } from '../build/build
import * as crypto from 'crypto';
import {
AppLifecycleStatus,
CustomDomainStatus,
DeploymentStatus,
isManagedProductType,
MANAGED_DEPLOY_MARKER,
@@ -16,7 +17,7 @@ import {
import { ClustersService } from '../clusters/clusters.service';
@Injectable()
export class DeploymentsService {
export class DeploymentsService implements OnModuleInit {
private readonly logger = new Logger(DeploymentsService.name);
constructor(
@@ -29,6 +30,46 @@ export class DeploymentsService {
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>.
* Generated once per application (see resolvePreviewNumber) and persisted.
@@ -58,6 +99,18 @@ export class DeploymentsService {
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId);
this.ensureAppPaidAndActive(app, 'deploying');
const inFlight = await this.deploymentsRepository.findOne({
where: {
applicationId: app.id,
status: In([DeploymentStatus.PENDING, DeploymentStatus.BUILDING, DeploymentStatus.DEPLOYING]),
},
});
if (inFlight) {
throw new BadRequestException('A deployment is already in progress for this application');
}
// Create deployment record
const deployment = this.deploymentsRepository.create({
applicationId: app.id,
@@ -71,7 +124,7 @@ export class DeploymentsService {
// Fill deterministic preview number after we have the deployment id.
let previewSubdomain: string | null = null;
if (!app.customDomain) {
if (!this.hasVerifiedCustomDomain(app)) {
previewSubdomain = await this.resolvePreviewNumber(app.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain;
@@ -297,7 +350,9 @@ export class DeploymentsService {
const failedClusterIds: string[] = [];
let currentApp = app;
let lastError: any;
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
const maxAttempts = process.env.CLUSTER_DEPLOY_FALLBACK_ENABLED === 'true'
? Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3)
: 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (await this.isDeploymentCancelled(deploymentId)) {
@@ -367,7 +422,9 @@ export class DeploymentsService {
const failedClusterIds: string[] = [];
let currentApp = app;
let lastError: any;
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
const maxAttempts = process.env.CLUSTER_DEPLOY_FALLBACK_ENABLED === 'true'
? Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3)
: 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (await this.isDeploymentCancelled(deploymentId)) {
@@ -451,15 +508,20 @@ export class DeploymentsService {
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 expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null;
const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date();
if (!isActive || !hasPaidTimeRemaining) {
throw new BadRequestException('Payment must be completed successfully before redeploying this application.');
if (!app.billingCycle || !isActive || !hasPaidTimeRemaining) {
throw new BadRequestException(
`Payment must be completed successfully before ${action} this application.`,
);
}
}
@@ -636,6 +698,9 @@ export class DeploymentsService {
async startDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
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.applicationsService.clearSuspendedReplicas(app.id);
@@ -671,7 +736,7 @@ export class DeploymentsService {
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
const deployment = this.deploymentsRepository.create({
@@ -685,7 +750,7 @@ export class DeploymentsService {
const saved = await this.deploymentsRepository.save(deployment);
let previewSubdomain: string | null = null;
if (!app.customDomain) {
if (!this.hasVerifiedCustomDomain(app)) {
previewSubdomain = await this.resolvePreviewNumber(app.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain;
@@ -703,4 +768,9 @@ export class DeploymentsService {
async deleteAllForApplication(applicationId: string): Promise<void> {
await this.deploymentsRepository.delete({ applicationId });
}
/** Preview stays available until the custom domain is verified (not merely requested). */
private hasVerifiedCustomDomain(app: { customDomain?: string | null; customDomainStatus?: CustomDomainStatus | null }): boolean {
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
}
}
@@ -5,6 +5,7 @@ import * as crypto from 'crypto';
import { ChildProcess, spawn } from 'child_process';
import { ClustersService } from '../clusters/clusters.service';
import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service';
import { userNamespace } from './k8s-workload.util';
interface ElasticsearchCredentials {
username: string;
@@ -97,9 +98,9 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
private configService: ConfigService,
private helmService: HelmService,
) {
this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure';
this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer';
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System';
this.ELASTIC_PASSWORD = this.configService.get('elasticsearch.password') || '';
this.FLUENTBIT_PASSWORD = this.configService.get('elasticsearch.fluentbitPassword') || '';
this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || '';
}
async onModuleInit(): Promise<void> {
@@ -649,7 +650,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
generateUserCredentials(userId: string): ElasticsearchCredentials {
const hash = crypto.createHash('sha256').update(`${userId}-${this.ELASTIC_PASSWORD}`).digest('hex');
return {
username: `user-${userId.split('-')[0]}`,
username: userNamespace(userId),
password: hash.substring(0, 24),
};
}
@@ -666,16 +667,16 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
* Get index pattern for a user's applications
*/
getIndexPattern(userId: string): string {
const userPrefix = userId.split('-')[0];
return `logs-user-${userPrefix}-*`;
return `logs-${userNamespace(userId)}-*`;
}
/**
* Build must clauses for user log isolation (new + legacy fields).
*/
buildUserLogMustClauses(userId: string, filters: LogSearchFilters = {}): any[] {
const userPrefix = userId.split('-')[0];
const namespace = `user-${userPrefix}`;
// Full-UUID namespace — a truncated prefix would match other tenants'
// namespaces and leak their logs.
const namespace = userNamespace(userId);
const must: any[] = [
{
@@ -758,7 +759,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
}
getUserIndexPattern(userId: string): string {
return `logs-user-${userId.split('-')[0]}-*`;
return `logs-${userNamespace(userId)}-*`;
}
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');
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');
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 () => {
@@ -144,7 +144,7 @@ describe('KubernetesService — k8s v1 client shape', () => {
expect(size).toBe('5Gi');
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 () => {
@@ -157,7 +157,7 @@ describe('KubernetesService — k8s v1 client shape', () => {
const [param, options] = appsApi.patchNamespacedDeployment.mock.calls[0];
expect(param).toMatchObject({
name: 'my-app',
namespace: 'user-abc123',
namespace: 'user-abc123def456',
body: { spec: { replicas: 3 } },
});
// 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 { 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 {
return `user-${userId.split('-')[0]}`;
return `user-${userIdSlug(userId)}`;
}
/** Primary pod label selector target for an application workload. */
@@ -15,7 +15,7 @@ describe('buildHelmValues logic', () => {
return {
app: {
name: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
namespace: `user-${app.userId.replace(/-/g, '')}`,
runtime: app.runtime,
image: imageUri,
port: app.port,
@@ -72,7 +72,7 @@ describe('buildHelmValues logic', () => {
it('should set correct namespace from userId', () => {
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', () => {
+220 -198
View File
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
@@ -17,6 +18,7 @@ import { HelmService } from './helm.service';
import { RegistryService } from './registry.service';
import { K8sClientService } from './k8s-client.service';
import { K8sLifecycleService } from './k8s-lifecycle.service';
import { userNamespace, userIdSlug } from './k8s-workload.util';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
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). */
/**
* 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> {
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const pullRegistryUrl = this.registryService.getRegistryUrl();
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const productType = app.productType;
@@ -247,7 +268,10 @@ export class KubernetesService implements OnModuleInit {
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
password:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app),
},
@@ -260,6 +284,7 @@ export class KubernetesService implements OnModuleInit {
ownerId: app.userId,
applicationId: app.id,
},
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`,
};
@@ -287,8 +312,10 @@ export class KubernetesService implements OnModuleInit {
private buildHelmValues(app: Application, imageUri: string, previewNumber?: string | null): Record<string, any> {
const domain = this.configService.get('platform.domain');
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0];
const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
const namespacePrefix = userIdSlug(app.userId);
const previewHost = previewNumber && !this.hasVerifiedCustomDomain(app)
? `${namespacePrefix}-${previewNumber}.${previewRootDomain}`
: '';
const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
const hasDb = app.databaseType !== DatabaseType.NONE;
@@ -299,7 +326,7 @@ export class KubernetesService implements OnModuleInit {
app: {
enabled: true,
name: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
namespace: this.getUserNamespace(app.userId),
runtime: app.runtime,
image: imageUri,
port: app.port,
@@ -330,7 +357,7 @@ export class KubernetesService implements OnModuleInit {
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
password: hasDb ? this.ensureDbPassword(app) : '',
storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app),
},
@@ -344,10 +371,11 @@ export class KubernetesService implements OnModuleInit {
logPaths: app.logPaths || [],
ownerId: app.userId,
applicationId: app.id,
elasticPassword: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure',
fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer',
kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System',
elasticPassword: this.configService.get<string>('elasticsearch.password'),
fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword'),
kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword'),
},
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
};
@@ -389,7 +417,7 @@ export class KubernetesService implements OnModuleInit {
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
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 workloads = [
...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []),
@@ -429,7 +457,7 @@ export class KubernetesService implements OnModuleInit {
async updateIngress(app: Application): Promise<void> {
const domain = this.configService.get('platform.domain');
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;
// When there's no verified custom domain, restore the stable preview host so
@@ -528,7 +556,7 @@ export class KubernetesService implements OnModuleInit {
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const context: ManifestContext = {
appName: app.name,
namespace,
@@ -545,7 +573,10 @@ export class KubernetesService implements OnModuleInit {
domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir',
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
@@ -602,7 +633,7 @@ export class KubernetesService implements OnModuleInit {
const context: ManifestContext = {
appName: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
namespace: this.getUserNamespace(app.userId),
image: imageUri,
port: app.port,
replicas: app.replicas,
@@ -616,7 +647,10 @@ export class KubernetesService implements OnModuleInit {
domain: domain,
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
@@ -1172,10 +1206,10 @@ export class KubernetesService implements OnModuleInit {
/** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */
private async ensureElasticsearchCredentialsSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
const name = 'elasticsearch-credentials';
const stringData = {
ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure',
FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer',
KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System',
const stringData: { [key: string]: string } = {
ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || '',
FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || '',
KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || '',
};
try {
@@ -1512,7 +1546,7 @@ export class KubernetesService implements OnModuleInit {
}
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}` : '';
if (previewHost) {
rules.push({
@@ -2228,7 +2262,7 @@ export class KubernetesService implements OnModuleInit {
async scaleDeployment(app: Application, replicas: number): Promise<void> {
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'));
}
@@ -2268,7 +2302,7 @@ export class KubernetesService implements OnModuleInit {
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
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> = {};
for (const workload of this.getApplicationWorkloadDeployments(app)) {
@@ -2297,7 +2331,7 @@ export class KubernetesService implements OnModuleInit {
*/
async suspendApplication(app: Application): Promise<Record<string, number>> {
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}`);
@@ -2315,6 +2349,8 @@ export class KubernetesService implements OnModuleInit {
}
}
await this.deleteTemporaryAccessServicesForApp(app);
return snapshot;
}
@@ -2323,7 +2359,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resumeApplication(app: Application): Promise<void> {
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}`);
@@ -2355,7 +2391,7 @@ export class KubernetesService implements OnModuleInit {
async restartDeployment(app: Application): Promise<void> {
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;
await appsApi.patchNamespacedDeployment(
@@ -2550,7 +2586,7 @@ export class KubernetesService implements OnModuleInit {
*/
async getResourceUsage(app: Application): Promise<any> {
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[] = [];
@@ -2640,7 +2676,7 @@ export class KubernetesService implements OnModuleInit {
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
): Promise<void> {
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);
if (!target) {
@@ -2685,7 +2721,12 @@ export class KubernetesService implements OnModuleInit {
}
getUserNamespace(userId: string): string {
return `user-${userId.split('-')[0]}`;
return userNamespace(userId);
}
/** Preview URL stays available until the custom domain is verified (not merely requested). */
private hasVerifiedCustomDomain(app: Application): boolean {
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
}
private getClusterHostIp(kc: k8s.KubeConfig): string {
@@ -2923,7 +2964,7 @@ export class KubernetesService implements OnModuleInit {
/**
* Get preview info for a deployed application.
* Patches the service to NodePort if needed, and returns the access URL.
* Returns ingress URL when available; only reads an existing NodePort (never patches ClusterIP).
*/
async getPreviewInfo(
app: Application,
@@ -2934,52 +2975,15 @@ export class KubernetesService implements OnModuleInit {
host: string;
ingressUrl?: string;
}> {
const { coreApi, networkingApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const domain = this.configService.get('platform.domain');
const hostIp = this.getClusterHostIp(kc);
// Read current service
let nodePort = 0;
try {
const svcResponse = await coreApi.readNamespacedService({
name: app.name,
namespace,
});
const svc = svcResponse;
if (svc.spec?.type === 'NodePort') {
// Already NodePort, read the assigned port
nodePort = svc.spec.ports?.[0]?.nodePort || 0;
} else {
// Patch ClusterIP → NodePort so we can access from outside
const patchBody = {
spec: {
type: 'NodePort',
ports: [
{
port: 80,
targetPort: app.port,
protocol: 'TCP',
},
],
},
};
const patchedResponse = await coreApi.patchNamespacedService({ name: app.name, namespace, body: patchBody }, k8s.setHeaderOptions('Content-Type', 'application/strategic-merge-patch+json'));
nodePort = patchedResponse.spec?.ports?.[0]?.nodePort || 0;
this.logger.log(`Patched service ${app.name} to NodePort: ${nodePort}`);
}
} catch (e: any) {
this.logger.warn(`Failed to get/patch service for ${app.name}: ${e.message}`);
throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`);
}
// Build ingress URL (main / custom domain / preview host)
const subdomain = app.subdomain || app.name;
const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null;
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}`;
if (verifiedCustomDomain) {
@@ -2988,8 +2992,23 @@ export class KubernetesService implements OnModuleInit {
ingressUrl = `https://${namespacePrefix}-${previewNumber}.${previewRootDomain}`;
}
let nodePort = 0;
try {
const svcResponse = await coreApi.readNamespacedService({
name: app.name,
namespace,
});
if (svcResponse.spec?.type === 'NodePort') {
nodePort = svcResponse.spec.ports?.[0]?.nodePort || 0;
}
} catch (e: any) {
this.logger.warn(`Failed to read service for ${app.name}: ${e.message}`);
}
const url = ingressUrl || (nodePort > 0 ? `http://${hostIp}:${nodePort}` : '');
return {
url: `http://${hostIp}:${nodePort}`,
url,
nodePort,
host: hostIp,
ingressUrl,
@@ -3362,7 +3381,7 @@ export class KubernetesService implements OnModuleInit {
*/
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
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 start = Date.now();
@@ -3469,14 +3488,12 @@ export class KubernetesService implements OnModuleInit {
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
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 ts = Date.now();
const pvcName = `${app.name}-db-dump-${ts}`;
const helperPodName = `${pvcName}-helper`;
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 pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024)));
@@ -3562,14 +3579,8 @@ export class KubernetesService implements OnModuleInit {
} catch {}
}
// ── 4. Build restore command ──
const command = isPostgres
? ['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}`;
// ── 4. Build restore command (per database type) ──
const { image, restoreCommand: command } = this.databaseDumpSpec(app, dbName);
// ── 5. Create the restore Job ──
const job: k8s.V1Job = {
@@ -3777,7 +3788,7 @@ export class KubernetesService implements OnModuleInit {
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 batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const oldPvcName = `${app.name}-db`;
const newPvcName = `${app.name}-db-resizable`;
const deploymentName = `${app.name}-db`;
@@ -3952,7 +3963,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
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`;
try {
@@ -4025,7 +4036,7 @@ export class KubernetesService implements OnModuleInit {
async getDatabasePvcSize(app: Application): Promise<string> {
try {
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 pvc = await coreApi.readNamespacedPersistentVolumeClaim({
@@ -4051,7 +4062,7 @@ export class KubernetesService implements OnModuleInit {
totalUsedGb: number;
}> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const result = {
database: null as StorageUsageSlice | null,
@@ -4260,7 +4271,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
try {
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
@@ -4317,7 +4328,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
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
let pvcName = `${app.name}-storage`;
@@ -4374,6 +4385,60 @@ export class KubernetesService implements OnModuleInit {
// ─── 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.
* Returns the dump as a Buffer, or null on failure.
@@ -4383,20 +4448,12 @@ export class KubernetesService implements OnModuleInit {
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
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 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';
const dbVer = app.dbVersion || defaultDbVer;
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`];
// Dump command writes to spec.outputPath, then sleeps to allow exec retrieval
const { image, outputPath, dumpCommand: command } = this.databaseDumpSpec(app, dbName);
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
@@ -4519,7 +4576,7 @@ export class KubernetesService implements OnModuleInit {
});
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();
else reject(new Error(status.message || 'exec failed'));
});
@@ -4564,7 +4621,7 @@ export class KubernetesService implements OnModuleInit {
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
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 jobName = `${app.name}-wp-archive-${Date.now()}`;
@@ -4712,127 +4769,92 @@ export class KubernetesService implements OnModuleInit {
/**
* 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 }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
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 jobName = `${app.name}-wp-restore-${Date.now()}`;
const secretName = `${jobName}-archive`;
const ts = Date.now();
const helperPodName = `${app.name}-wp-restore-${ts}`;
// Store archive in a secret
const archiveSecret = {
const helperPod: k8s.V1Pod = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: secretName, 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 },
kind: 'Pod',
metadata: { name: helperPodName, namespace },
spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'restore',
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"'],
volumeMounts: [
{ name: 'wp-content', mountPath: '/wp-content' },
{ name: 'archive', mountPath: '/archive', readOnly: true },
],
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'wp-content', mountPath: '/wp-content' }],
resources: {
requests: { cpu: '100m', memory: '64Mi' },
limits: { cpu: '500m', memory: '256Mi' },
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
},
],
volumes: [
{
name: 'wp-content',
persistentVolumeClaim: { claimName: pvcName },
},
{ name: 'archive', secret: { secretName } },
],
},
},
volumes: [{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } }],
restartPolicy: 'Never',
},
};
const tmpArchive = path.join(os.tmpdir(), `wp-content-restore-${ts}.tar.gz`);
const tmpKubeconfig = path.join(os.tmpdir(), `kubeconfig-wprestore-${ts}.yaml`);
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) {
this.logger.error(`wp-content restore failed for ${app.name}: ${e.message}`);
return { success: false, logs: e.message || 'wp-content restore failed' };
} finally {
try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace });
fs.unlinkSync(tmpArchive);
} 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 {
const st = await batchApi.readNamespacedJob({
name: jobName,
namespace,
});
if (st.status?.succeeded && st.status.succeeded > 0) {
succeeded = true;
break;
}
if (st.status?.failed && st.status.failed > 0) {
failed = true;
break;
}
fs.unlinkSync(tmpKubeconfig);
} catch {}
try {
await coreApi.deleteNamespacedPod({ name: helperPodName, namespace });
} 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 ─────────────────────
@@ -4852,7 +4874,7 @@ export class KubernetesService implements OnModuleInit {
}>;
currentRevision: number;
}> {
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name;
try {
@@ -4890,7 +4912,7 @@ export class KubernetesService implements OnModuleInit {
* Rollback a Helm release to a specific revision.
*/
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;
try {
+2 -1
View File
@@ -18,6 +18,7 @@ import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ElasticsearchService } from './elasticsearch.service';
import { userNamespace } from './k8s-workload.util';
import { Application } from '../applications/entities/application.entity';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@@ -225,7 +226,7 @@ export class LogsController {
if (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 query = filterParts.length > 0 ? filterParts.join(' AND ') : '*';
+50 -11
View File
@@ -28,6 +28,29 @@ export class RegistryService {
return url.replace(/^https?:\/\//, '');
}
/** Docker auth key — hostname[:port] only, no repository path prefix. */
getRegistryHost(): string {
const url = this.getRegistryUrl();
const slash = url.indexOf('/');
return slash === -1 ? url : url.slice(0, slash);
}
/** Push target host[:port][/project] — Kaniko destination (may differ from pull URL on Harbor). */
getRegistryPushUrl(): string {
const buildNs = this.getBuildNamespace();
const url =
this.configService.get<string>('registry.url') ||
`registry.${buildNs}.svc.cluster.local:5000`;
return url.replace(/^https?:\/\//, '');
}
/** Push target host:port only, no repository path prefix. */
getRegistryPushHost(): string {
const url = this.getRegistryPushUrl();
const slash = url.indexOf('/');
return slash === -1 ? url : url.slice(0, slash);
}
getRegistryCredentials(): { username: string; password: string } {
return {
username: this.configService.get<string>('registry.username') || 'admin',
@@ -39,6 +62,11 @@ export class RegistryService {
return `${this.getRegistryUrl()}/${userId}/${appName}:${tag}`;
}
/** Kaniko push target — uses registry.url (in-cluster harbor-registry on Harbor setups). */
buildPushImageReference(userId: string, appName: string, tag: string): string {
return `${this.getRegistryPushUrl()}/${userId}/${appName}:${tag}`;
}
parseImageReference(imageRef: string): ParsedImageReference {
const normalized = imageRef.replace(/^https?:\/\//, '');
const slashIdx = normalized.indexOf('/');
@@ -56,24 +84,35 @@ export class RegistryService {
};
}
/** Re-point any stored image (e.g. legacy external host) to the in-cluster registry. */
/** Re-point any stored image (e.g. push host) to the pull registry URL for kubelet. */
normalizeImageReference(imageRef: string): string {
const { repository, tag } = this.parseImageReference(imageRef);
return `${this.getRegistryUrl()}/${repository}:${tag}`;
const pullBase = this.getRegistryUrl().replace(/\/$/, '');
const slash = pullBase.indexOf('/');
const pullPath = slash === -1 ? '' : pullBase.slice(slash + 1);
let repo = repository;
if (pullPath && (repo === pullPath || repo.startsWith(`${pullPath}/`))) {
repo = repo === pullPath ? '' : repo.slice(pullPath.length + 1);
}
if (!repo) {
throw new Error(`Invalid image reference after normalization: ${imageRef}`);
}
return `${pullBase}/${repo}:${tag}`;
}
buildDockerConfigJson(): string {
const { username, password } = this.getRegistryCredentials();
const auth = username && password ? Buffer.from(`${username}:${password}`).toString('base64') : '';
const host = this.getRegistryUrl();
return JSON.stringify({
auths: {
[host]: { auth },
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: {
auth,
},
},
});
const pullHost = this.getRegistryHost();
const pushHost = this.getRegistryPushHost();
const auths: Record<string, { auth: string }> = {
[pullHost]: { auth },
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: { auth },
};
if (pushHost !== pullHost) {
auths[pushHost] = { auth };
}
return JSON.stringify({ auths });
}
async ensureRegistryPullSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
+21 -11
View File
@@ -226,28 +226,38 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
if (!app.billingCycle) return false;
try {
const cost = await this.billingService.calculateCostForApp(app);
return await this.appRepo.manager.transaction(async (em) => {
const locked = await em.findOne(Application, {
where: { id: app.id },
lock: { mode: 'pessimistic_write' },
});
if (!locked?.billingCycle) return false;
if (locked.lifecycleStatus !== AppLifecycleStatus.ACTIVE) return false;
if (locked.planExpiresAt && locked.planExpiresAt.getTime() > Date.now()) {
return false; // another replica already renewed
}
const cost = await this.billingService.calculateCostForApp(locked);
const hourlyAmount = cost.hourly;
if (hourlyAmount <= 0) return false;
// Check wallet balance
const { balance } = await this.billingService.getBalance(app.userId);
const { balance } = await this.billingService.getBalance(locked.userId);
if (balance < hourlyAmount) return false;
// Deduct and renew
await this.billingService.deductWallet(
app.userId,
locked.userId,
hourlyAmount,
`Auto-renew hourly: ${app.name}`,
app.id,
`Auto-renew hourly: ${locked.name}`,
locked.id,
);
app.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
app.lifecycleStatus = AppLifecycleStatus.ACTIVE;
await this.appRepo.save(app);
locked.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
locked.lifecycleStatus = AppLifecycleStatus.ACTIVE;
await em.save(locked);
this.logger.log(`Auto-renewed hourly plan for ${app.name} — deducted ${hourlyAmount} Toman`);
this.logger.log(`Auto-renewed hourly plan for ${locked.name} — deducted ${hourlyAmount} Toman`);
return true;
});
} catch (e: any) {
this.logger.warn(`Auto-renew failed for ${app.name}: ${e.message}`);
return false;
+12 -3
View File
@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
import { ClassSerializerInterceptor, Logger, ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
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
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()
.setTitle('CloudHost PaaS API')
.setDescription('Self-service PaaS platform API')
@@ -59,10 +65,13 @@ async function bootstrap() {
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.PORT || 4000;
await app.listen(port);
console.log(`🚀 CloudHost API running on http://localhost:${port}`);
if (swaggerEnabled) {
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
}
}
bootstrap();
@@ -6,6 +6,7 @@ import {
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { UserRole } from '../../common/enums';
import { Application } from '../../applications/entities/application.entity';
@@ -30,6 +31,8 @@ export class User {
@Column({ default: false })
phoneVerified: boolean;
/** Bcrypt hash — never serialized into API responses. */
@Exclude({ toPlainOnly: true })
@Column()
password: string;
+4 -2
View File
@@ -12,6 +12,7 @@ import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { UserRole } from '../common/enums';
import { normalizeIranMobile } from '../common/phone.util';
import { userNamespace } from '../kubernetes/k8s-workload.util';
@Injectable()
export class UsersService {
@@ -22,9 +23,10 @@ export class UsersService {
async create(data: Partial<User>): Promise<User> {
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);
saved.namespace = `user-${saved.id.split('-')[0]}`;
saved.namespace = userNamespace(saved.id);
return this.usersRepository.save(saved);
}
+9 -5
View File
@@ -13,6 +13,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThan, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { VerificationCode } from './entities/verification-code.entity';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
@@ -185,7 +186,7 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
{ consumedAt: new Date() },
);
const code = String(Math.floor(100000 + Math.random() * 900000)); // 6 digits
const code = String(crypto.randomInt(100000, 1000000)); // 6 digits, CSPRNG
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
const record = this.codeRepo.create({
userId,
@@ -208,9 +209,11 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
purpose: VerificationPurpose,
code: string,
): Promise<VerificationCode> {
const record = await this.codeRepo.findOne({
return this.codeRepo.manager.transaction(async (em) => {
const record = await em.findOne(VerificationCode, {
where: { userId, purpose, consumedAt: IsNull() },
order: { createdAt: 'DESC' },
lock: { mode: 'pessimistic_write' },
});
if (!record || record.expiresAt.getTime() < Date.now()) {
@@ -218,20 +221,21 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
}
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
record.consumedAt = new Date();
await this.codeRepo.save(record);
await em.save(record);
throw new BadRequestException('Too many attempts — request a new code');
}
const ok = await bcrypt.compare(code, record.codeHash);
if (!ok) {
record.attempts += 1;
await this.codeRepo.save(record);
await em.save(record);
throw new BadRequestException('Invalid code');
}
record.consumedAt = new Date();
await this.codeRepo.save(record);
await em.save(record);
return record;
});
}
/** Best-effort cleanup of long-expired codes (called opportunistically). */
+1 -1
View File
@@ -47,7 +47,7 @@ services:
redis:
condition: service_healthy
environment:
NODE_ENV: production
NODE_ENV: development
PORT: 4000
# Database
+4 -3
View File
@@ -1,5 +1,6 @@
# ---- Stage 1: Dependencies ----
FROM node:24-alpine AS deps
ARG BASE_IMAGE=node:24-alpine
FROM ${BASE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
@@ -8,7 +9,7 @@ RUN sed -i 's|https://registry.npmmirror.com|https://registry.npmjs.org|g' packa
&& npm ci
# ---- Stage 2: Build ----
FROM node:24-alpine AS builder
FROM ${BASE_IMAGE} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
@@ -20,7 +21,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- Stage 3: Production ----
FROM node:24-alpine AS production
FROM ${BASE_IMAGE} AS production
RUN apk add --no-cache dumb-init
@@ -1566,7 +1566,7 @@ export default function AppDetailPage() {
<GitBranch className="w-3 h-3" /> {app.gitBranch}
</span>
)}
{app.gitToken && (
{(app.hasGitToken ?? app.gitToken) && (
<span className="text-xs text-green-600 flex items-center gap-1">
<KeyRound className="w-3 h-3" />{ad.private}</span>
)}
+2
View File
@@ -33,6 +33,8 @@ export interface Application {
appStorageSize?: string;
gitUrl?: string;
gitToken?: string;
/** Server-provided indicator; raw gitToken is no longer returned by the API. */
hasGitToken?: boolean;
gitBranch?: string;
codePath?: string;
envVars?: Record<string, string>;
+28 -10
View File
@@ -1,5 +1,7 @@
# GitOps stack for abrban.com
> **راهنمای استقرار از صفر (هر محیط):** [`RUNBOOK-DEPLOY.fa.md`](../RUNBOOK-DEPLOY.fa.md) — متغیرها، values، Sealed Secrets، logging، deploy، greenfield reset.
## DNS (A record → cluster IP `78.157.39.52`)
| Host | Purpose |
@@ -46,31 +48,47 @@ helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
helm upgrade --install gitea gitea-charts/gitea -n gitea --create-namespace \
-f gitops/gitea/values.yaml --timeout 15m --wait
# 4. TLS + registry secrets در namespaceهای argocd/gitea/cloudhost-builds
# 4. TLS + registry + egress secrets در namespaceهای argocd/gitea/cloudhost-builds
for ns in argocd gitea cloudhost-builds; do
kubectl -n cloudhost get secret abrban-wildcard-tls -o yaml | sed "s/namespace: cloudhost/namespace: ${ns}/" | kubectl apply -f -
kubectl -n cloudhost get secret registry-pull-secret -o yaml | sed "s/namespace: cloudhost/namespace: ${ns}/" | kubectl apply -f -
done
for ns in gitea cloudhost-builds; do
kubectl -n cloudhost get secret registry-egress-proxy -o yaml | sed "s/namespace: cloudhost/namespace: ${ns}/" | kubectl apply -f -
done
# 5. git push سورس به Gitea (نه docker push)
# 5. Seed ایمیج‌های CI در Harbor abrban/ (یک‌بار — الزامی قبل از Pipeline)
kubectl apply -f gitops/jobs/seed-ci-images.yaml
kubectl -n cloudhost wait --for=condition=complete job/seed-ci-images --timeout=15m
# 6. Sealed Secrets controller
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \
-n kube-system -f gitops/sealed-secrets/values.yaml --timeout 10m --wait
# 7. git push سورس به Gitea
# git remote add gitea https://git.abrban.com/abrban/cloud-host.git
# git push gitea main
# 6. اولین build در کلاستر (Kaniko → abrban/)
./scripts/trigger-platform-build.sh
# 7. Deploy
./scripts/gitops-deploy.sh
# ریپوی GitOps: https://git.abrban.com/abrban/cloud-host-gitops (SealedSecretها + values)
# 8. Gitea Actions runner
# توکن ثبت: SealedSecret در cloud-host-gitops/sealed-secrets/gitea-act-runner-token.yaml
kubectl apply -f gitops/gitea/act-runner.yaml
# 9. Argo CD Application
# 9. Argo CD Application (multi-source: chart از cloud-host، values از cloud-host-gitops)
kubectl apply -f gitops/argocd/application-platform.yaml
# 10. اولین deploy: push به main → Gitea Actions → Argo CD sync (جریان عادی)
# برای bootstrap دستی: TAG=... ./scripts/gitops-deploy.sh
```
## CI/CD
Gitea Actions: [.gitea/workflows/build-deploy.yaml](../.gitea/workflows/build-deploy.yaml)
Push به `main`Kaniko (از Harbor proxy) → push به `abrban/` → ArgoCD sync.
Push به `main`**تست Jest** → Kaniko → push به `abrban/` → کامیت tag در ریپوی [cloud-host-gitops](https://git.abrban.com/abrban/cloud-host-gitops) → ArgoCD sync.
- **استقرار اولیه از صفر:** [`RUNBOOK-DEPLOY.fa.md`](../RUNBOOK-DEPLOY.fa.md) (فاز ۲–۷)
- مقادیر Production: [`platform/values-abrban.example.yaml`](platform/values-abrban.example.yaml) → کپی به gitops و ویرایش
- SealedSecretهای نمونه: [`sealed-secrets/abrban-platform-secrets.example.yaml`](sealed-secrets/abrban-platform-secrets.example.yaml)، [`sealed-secrets/elasticsearch-credentials.example.yaml`](sealed-secrets/elasticsearch-credentials.example.yaml)
- Pipeline و rollback: [RUNBOOK-CICD.fa.md](../RUNBOOK-CICD.fa.md)
+9 -5
View File
@@ -1,19 +1,23 @@
# Source of truth for this file: cloud-host-gitops repo (argocd/application-platform.yaml).
# Kept here as a mirror for bootstrap convenience.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: abrban-platform
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://git.abrban.com/abrban/cloud-host.git
# Multi-source: chart lives in the app repo, values (GitOps state) live in cloud-host-gitops.
sources:
- repoURL: https://git.abrban.com/abrban/cloud-host.git
targetRevision: main
path: backend/helm/cloudhost-platform
helm:
valueFiles:
- ../../../gitops/platform/values-abrban.yaml
- $values/platform/values-abrban.yaml
- repoURL: https://git.abrban.com/abrban/cloud-host-gitops.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: cloudhost
+69 -16
View File
@@ -1,12 +1,48 @@
# Gitea Actions runner — host mode + Kaniko (no docker.sock; k3s uses containerd)
# Gitea Actions runner — host mode (no docker.sock; k3s uses containerd)
# Runner creates Kaniko Jobs in cloudhost-builds for image builds.
#
# Secrets (never commit real values):
#
# # Registration token: managed as SealedSecret in the cloud-host-gitops repo
# # (sealed-secrets/gitea-act-runner-token.yaml). To rotate manually:
# # token from Gitea UI → Site Administration → Actions → Runners, then:
# kubectl -n gitea create secret generic gitea-act-runner-token \
# --from-literal=token='<RUNNER_REGISTRATION_TOKEN>'
#
# # Egress proxy (copied from cloudhost namespace, needed by the kubectl download):
# kubectl -n cloudhost get secret registry-egress-proxy -o yaml \
# | sed 's/namespace: cloudhost/namespace: gitea/' | kubectl apply -f -
---
apiVersion: v1
kind: Secret
kind: ServiceAccount
metadata:
name: gitea-act-runner-token
name: gitea-act-runner
namespace: gitea
type: Opaque
stringData:
token: "nL63VkZEyqpCNFdF3AMM9wzQLdLlATUvXSe5Tj0R"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: gitea-act-runner
rules:
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "watch", "list", "delete"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: gitea-act-runner
subjects:
- kind: ServiceAccount
name: gitea-act-runner
namespace: gitea
roleRef:
kind: ClusterRole
name: gitea-act-runner
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
@@ -23,23 +59,35 @@ spec:
labels:
app: gitea-act-runner
spec:
serviceAccountName: gitea-act-runner
imagePullSecrets:
- name: registry-pull-secret
initContainers:
- name: kaniko-bin
image: registry.abrban.com/abrban/kaniko-executor:v1.23.2
- name: install-kubectl
image: registry.abrban.com/abrban/alpine:3
imagePullPolicy: IfNotPresent
command: ["sh", "-c", "cp /kaniko/executor /kaniko-bin/executor && chmod +x /kaniko-bin/executor"]
env:
- name: HTTPS_PROXY
valueFrom:
secretKeyRef:
name: registry-egress-proxy
key: HTTPS_PROXY
command:
- sh
- -c
- |
wget -q "https://dl.k8s.io/release/v1.35.0/bin/linux/amd64/kubectl" -O /tools/kubectl
chmod +x /tools/kubectl
volumeMounts:
- name: kaniko-bin
mountPath: /kaniko-bin
- name: tools
mountPath: /tools
containers:
- name: runner
image: registry.abrban.com/abrban/act-runner:0.2.11
imagePullPolicy: IfNotPresent
env:
- name: GITEA_INSTANCE_URL
value: https://git.abrban.com
value: http://gitea-http.gitea.svc.cluster.local:3000
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
@@ -48,10 +96,15 @@ spec:
- name: GITEA_RUNNER_NAME
value: k8s-abr-runner
- name: GITEA_RUNNER_LABELS
value: abrban-kaniko:host
value: abrban-builder:host
volumeMounts:
- name: kaniko-bin
mountPath: /kaniko
- name: tools
mountPath: /usr/local/bin/kubectl
subPath: kubectl
- name: runner-data
mountPath: /data
volumes:
- name: kaniko-bin
- name: tools
emptyDir: {}
- name: runner-data
emptyDir: {}
+124
View File
@@ -0,0 +1,124 @@
# registry.abrban.com — Traefik path split
#
# Proxy-cache projects (proxy-dockerhub, proxy-gcr, …) MUST hit harbor-core so
# Harbor can pull upstream on demand. harbor-registry only stores blobs; it does
# not run proxy-cache logic → 404 for uncached proxy paths.
#
# Direct pushes (abrban/, rook/) stay on harbor-registry where Kaniko/skopeo
# wrote the blobs.
#
# Apply: kubectl apply -f gitops/harbor/registry-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: registry
namespace: cloudhost
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: cloudhost-long-timeout@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- registry.abrban.com
secretName: abrban-wildcard-tls
rules:
- host: registry.abrban.com
http:
paths:
# ── Proxy-cache (harbor-core serves v2 + on-demand upstream pull) ──
- path: /v2/proxy-dockerhub/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-gcr/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-quay/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-k8s/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-gitea/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
# ── abrban/rook project images (served by harbor-core; required for k3s mirror pulls) ──
- path: /v2/abrban/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/rook/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
# ── Legacy registry (platform images pre-Harbor) ──
- path: /v2/
pathType: Prefix
backend:
service:
name: registry
port:
number: 5000
# ── Harbor UI / API ──
- path: /api/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /service/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /c/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /chartrepo/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: harbor-portal
port:
number: 80
+1 -1
View File
@@ -31,7 +31,7 @@ spec:
mountPath: /workspace
containers:
- name: kaniko
image: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2
image: registry.abrban.com/abrban/kaniko-executor:v1.23.2
env:
- name: IMAGE_TAG
value: bootstrap
@@ -0,0 +1,36 @@
# Example only — DO NOT commit real credentials.
# The real secret is managed as a SealedSecret in the cloud-host-gitops repo
# (sealed-secrets/kaniko-harbor-auth.yaml).
#
# Kaniko pushes directly to the internal registry endpoint
# (harbor-registry.cloudhost.svc.cluster.local:5000), which bypasses harbor-core.
# That endpoint only accepts the internal registry credential — Harbor robot
# accounts do NOT work there (their tokens are issued by harbor-core's token
# service). Use the harbor_registry_user credential from the harbor-core secret:
#
# REG_PASS="$(kubectl -n cloudhost get secret harbor-core \
# -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)"
# kubectl -n cloudhost-builds create secret docker-registry kaniko-harbor-auth \
# --docker-server=harbor-registry.cloudhost.svc.cluster.local:5000 \
# --docker-username=harbor_registry_user \
# --docker-password="${REG_PASS}"
#
# The build-deploy workflow mounts this secret at /kaniko/.docker/config.json
# inside every Kaniko Job. See RUNBOOK-CICD.fa.md for the full procedure.
apiVersion: v1
kind: Secret
metadata:
name: kaniko-harbor-auth
namespace: cloudhost-builds
type: kubernetes.io/dockerconfigjson
stringData:
.dockerconfigjson: |
{
"auths": {
"harbor-registry.cloudhost.svc.cluster.local:5000": {
"username": "harbor_registry_user",
"password": "<REGISTRY_CREDENTIAL_PASSWORD>",
"auth": "<base64 of username:password>"
}
}
}
+35 -6
View File
@@ -1,4 +1,13 @@
# Bootstrap: copy act_runner + kaniko into abrban/ (kubelet cannot use proxy-cache reliably)
# Bootstrap: seed CI/build images into Harbor project abrban/
#
# kubelet and Kaniko cannot reliably use Harbor proxy-cache for all images.
# Run once after Harbor is up and registry-egress-proxy exists in cloudhost:
#
# kubectl apply -f gitops/jobs/seed-ci-images.yaml
# kubectl -n cloudhost wait --for=condition=complete job/seed-ci-images --timeout=15m
#
# Images copied (see RUNBOOK-CICD.fa.md):
# abrban/act-runner, abrban/alpine, abrban/alpine-git, abrban/node, abrban/kaniko-executor
apiVersion: batch/v1
kind: Job
metadata:
@@ -14,7 +23,8 @@ spec:
- name: registry-pull-secret
containers:
- name: skopeo
image: registry.abrban.com/proxy-quay/skopeo/stable:latest
image: quay.io/skopeo/stable:latest
imagePullPolicy: IfNotPresent
envFrom:
- secretRef:
name: registry-egress-proxy
@@ -24,10 +34,29 @@ spec:
- |
set -eux
DEST="docker://harbor-registry.cloudhost.svc.cluster.local:5000/abrban"
skopeo copy --dest-tls-verify=false \
docker://docker.gitea.com/gitea/act_runner:0.2.11 \
# Must match harbor-core → REGISTRY_CREDENTIAL_PASSWORD (default: harbor_registry_password)
CREDS="harbor_registry_user:harbor_registry_password"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/gitea/act_runner:0.2.11 \
"${DEST}/act-runner:0.2.11"
skopeo copy --dest-tls-verify=false \
docker://gcr.io/kaniko-project/executor:v1.23.2 \
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/library/alpine:3.19 \
"${DEST}/alpine:3.19"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/alpine/git:2.43.0 \
"${DEST}/alpine-git:2.43.0"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/library/node:24-alpine \
"${DEST}/node:24-alpine"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/library/node:20-alpine \
"${DEST}/node:20-alpine"
# Tag present in Harbor abrban/ — seed via proxy-gcr (see seed-ci-images.yaml).
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 \
"${DEST}/kaniko-executor:v1.23.2"
# Alias for CI/configs that reference the debug tag name.
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://harbor-registry.cloudhost.svc.cluster.local:5000/abrban/kaniko-executor:v1.23.2 \
"${DEST}/kaniko-executor:v1.27.6-debug"
echo SEED_OK
+168
View File
@@ -0,0 +1,168 @@
# Production values template — copy and customize for YOUR environment.
#
# Full step-by-step (from zero, any cluster):
# See RUNBOOK-DEPLOY.fa.md — Phase 2 (values) and Phase 3 (secrets)
#
# Example for abrban.com:
# cp values-abrban.example.yaml ../cloud-host-gitops/platform/values-abrban.yaml
#
# CI only updates images.backend.tag and images.frontend.tag on each deploy.
namespace: cloudhost
createNamespace: false
registry:
credentialsSecret: harbor-core
credentialsPasswordKey: REGISTRY_CREDENTIAL_PASSWORD
username: harbor_registry_user
global:
storageClass: local-path
images:
# Harbor proxy-cache — first pull is slow, no manual seed needed (see gitops/README.md)
postgres: registry.abrban.com/proxy-dockerhub/library/postgres:16-alpine
redis: registry.abrban.com/proxy-dockerhub/library/redis:7-alpine
busybox: registry.abrban.com/proxy-dockerhub/library/busybox:1.36
backend:
repository: registry.abrban.com/abrban/cloudhost-backend
tag: "1.0.0" # ← CI overwrites on each deploy
pullPolicy: IfNotPresent
frontend:
repository: registry.abrban.com/abrban/cloudhost-frontend
tag: "1.0.0" # ← CI overwrites on each deploy
pullPolicy: IfNotPresent
# Kaniko job images — Harbor proxy-cache (first pull is slow, no manual seed needed).
build:
images:
kaniko: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
alpine: registry.abrban.com/abrban/alpine:3.19
alpineGit: registry.abrban.com/abrban/alpine-git:2.43.0
baseImageRegistry: registry.abrban.com/abrban
# Kaniko + init containers (npm/apk/composer/pip/git clone) on restricted egress.
egressProxySecret: registry-egress-proxy
postgres:
enabled: true
database: cloudhost
username: cloudhost
password: "" # managed in abrban-platform-secrets (postgres-password)
storage: 10Gi
imagePullSecrets:
- name: registry-pull-secret
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
redis:
enabled: true
storage: 1Gi
password: "" # managed in abrban-platform-secrets (redis-password)
imagePullSecrets:
- name: registry-pull-secret
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 512Mi
# GitOps: never let Helm generate random JWT/redis passwords on each sync.
# Create once with kubeseal — see gitops/sealed-secrets/abrban-platform-secrets.example.yaml
secrets:
existingSecret: abrban-platform-secrets
backend:
enabled: true
replicas: 1
imagePullSecrets:
- name: registry-pull-secret
uploads:
size: 20Gi
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
env:
NODE_ENV: production
PORT: "4000"
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
PLATFORM_DOMAIN: apps.abrban.com
PREVIEW_BASE_DOMAIN: apps.abrban.com
FRONTEND_URL: https://panel.abrban.com,https://abrban.com
REGISTRY_URL: harbor-registry.cloudhost.svc.cluster.local:5000/abrban
REGISTRY_PULL_URL: registry.abrban.com/abrban
BUILD_NAMESPACE: cloudhost-builds
BUILD_SERVICE_ACCOUNT: kaniko-builder
UPLOAD_DIR: /app/uploads
PLATFORM_CREATE_STORAGE_CLASS: "true"
PLATFORM_STORAGE_CLASS: cloudhost-expandable
PLATFORM_STORAGE_PROVISIONER: rancher.io/local-path
ELASTICSEARCH_HOST: elasticsearch.logging.svc.cluster.local
ELASTICSEARCH_AUTO_PORT_FORWARD: "false"
# Must match elasticsearch-credentials Secret in logging namespace (not in Helm chart)
ELASTIC_PASSWORD: "CHANGE_VIA_SEALEDSECRET_OR_KUBECTL"
FLUENTBIT_PASSWORD: "CHANGE_VIA_SEALEDSECRET_OR_KUBECTL"
KIBANA_SYSTEM_PASSWORD: "CHANGE_VIA_SEALEDSECRET_OR_KUBECTL"
# Swagger disabled in production unless explicitly enabled
# SWAGGER_ENABLED: "true"
# OTP SMS — username/password in abrban-platform-secrets (SealedSecret).
sms:
enabled: true
provider: mizbansms
from: "5000467254"
api: "2016"
userType: "2"
frontend:
enabled: true
replicas: 1
imagePullSecrets:
- name: registry-pull-secret
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
ingress:
enabled: true
className: traefik
frontend:
host: abrban.com
panel:
host: panel.abrban.com
api:
host: api.abrban.com
tls:
enabled: true
clusterIssuer: letsencrypt-prod
migrations:
enabled: true
image: registry.abrban.com/proxy-dockerhub/library/postgres:16-alpine
backups:
postgres:
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
retentionDays: 7
monitoring:
enabled: false
-56
View File
@@ -1,56 +0,0 @@
# Production values for abrban.com — used by ArgoCD / Gitea Actions GitOps
namespace: cloudhost
createNamespace: false
global:
storageClass: local-path
images:
backend:
repository: registry.abrban.com/abrban/cloudhost-backend
tag: "1.0.0"
pullPolicy: Always
frontend:
repository: registry.abrban.com/abrban/cloudhost-frontend
tag: "1.0.0"
pullPolicy: Always
backend:
imagePullSecrets:
- name: registry-pull-secret
sourceStorage:
enabled: true
existingSecret: ceph-app-sources-credentials
env:
NODE_ENV: production
PORT: "4000"
PLATFORM_DOMAIN: apps.abrban.com
REGISTRY_URL: registry.abrban.com
REGISTRY_PULL_URL: registry.abrban.com
BUILD_NAMESPACE: cloudhost-builds
BUILD_SERVICE_ACCOUNT: kaniko-builder
UPLOAD_DIR: /app/uploads
PLATFORM_CREATE_STORAGE_CLASS: "false"
PLATFORM_STORAGE_CLASS: rook-ceph-block
PLATFORM_STORAGE_PROVISIONER: rook-ceph.rbd.csi.ceph.com
ELASTICSEARCH_HOST: elasticsearch.logging.svc.cluster.local
ELASTICSEARCH_AUTO_PORT_FORWARD: "false"
frontend:
imagePullSecrets:
- name: registry-pull-secret
ingress:
enabled: true
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
frontend:
host: abrban.com
panel:
host: panel.abrban.com
api:
host: api.abrban.com
tls:
enabled: true
secretName: abrban-wildcard-tls
@@ -0,0 +1,35 @@
# Example: seal platform secrets for namespace cloudhost.
# Full guide (any environment): RUNBOOK-DEPLOY.fa.md — Phase 3
# Real SealedSecret lives in cloud-host-gitops/sealed-secrets/ — never commit plaintext passwords.
#
# Required keys (must match backend Deployment + validate-production-config):
# postgres-password, jwt-secret, jwt-refresh-secret, cluster-kubeconfig-key,
# redis-password, elastic-password (must match elasticsearch-credentials in logging),
# mizbansms-username, mizbansms-password (OTP SMS — required when backend.sms.enabled)
#
# Generate (replace CHANGE_ME_* with strong random values):
#
# kubectl -n cloudhost create secret generic abrban-platform-secrets \
# --from-literal=postgres-password='CHANGE_ME_PG' \
# --from-literal=jwt-secret='CHANGE_ME_JWT_32CHARS_MIN' \
# --from-literal=jwt-refresh-secret='CHANGE_ME_REFRESH_32CHARS_MIN' \
# --from-literal=cluster-kubeconfig-key='0123456789abcdef0123456789abcdef' \
# --from-literal=redis-password='CHANGE_ME_REDIS' \
# --from-literal=elastic-password='CHANGE_ME_ELASTIC' \
# --from-literal=mizbansms-username='CHANGE_ME_SMS_USER' \
# --from-literal=mizbansms-password='CHANGE_ME_SMS_PASS' \
# --dry-run=client -o json \
# | kubeseal \
# --controller-name=sealed-secrets-controller \
# --controller-namespace=kube-system \
# --format yaml \
# > ../cloud-host-gitops/sealed-secrets/abrban-platform-secrets.yaml
#
# Then in platform/values-abrban.yaml:
# secrets:
# existingSecret: abrban-platform-secrets
#
# Apply:
# kubectl apply -f ../cloud-host-gitops/sealed-secrets/abrban-platform-secrets.yaml
#
# Rotate redis-password: update SealedSecret, sync Argo, restart backend + redis pods.
@@ -0,0 +1,20 @@
# Example: seal Elasticsearch stack credentials (namespace logging).
# Full guide (any environment): RUNBOOK-DEPLOY.fa.md — Phase 4
# Apply elasticsearch-stack.yaml FIRST (without inline passwords), then create this Secret.
#
# kubectl -n logging create secret generic elasticsearch-credentials \
# --from-literal=ELASTIC_PASSWORD="$(openssl rand -base64 24)" \
# --from-literal=FLUENTBIT_PASSWORD="$(openssl rand -base64 24)" \
# --dry-run=client -o json \
# | kubeseal \
# --controller-name=sealed-secrets-controller \
# --controller-namespace=kube-system \
# --format yaml \
# > ../cloud-host-gitops/sealed-secrets/elasticsearch-credentials.yaml
#
# Backend must receive the same ELASTIC_* values via backend.env in values-abrban.yaml
# (or a separate SealedSecret referenced with envFrom).
#
# After deploy, verify:
# kubectl -n logging get secret elasticsearch-credentials
# curl -u elastic:$ELASTIC_PASSWORD https://elasticsearch.logging.svc.cluster.local:9200
+15
View File
@@ -0,0 +1,15 @@
# Sealed Secrets controller — encrypts secrets so they can live in Git.
# Install:
# helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
# helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \
# -n kube-system -f gitops/sealed-secrets/values.yaml
#
# fullnameOverride matches the default the kubeseal CLI expects
# (sealed-secrets-controller in kube-system), so kubeseal works without flags.
fullnameOverride: sealed-secrets-controller
image:
# Seeded into Harbor abrban/ project from ghcr.io/bitnami (proxy-ghcr returned 401
# for kubelet pulls; see seed job pattern in gitops/jobs/seed-ci-images.yaml).
registry: registry.abrban.com
repository: abrban/sealed-secrets-controller
+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}`);
+14 -1
View File
@@ -5,9 +5,22 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NAMESPACE="${NAMESPACE:-cloudhost}"
RELEASE="${RELEASE:-cloudhost}"
VALUES="${VALUES:-${ROOT}/gitops/platform/values-abrban.yaml}"
# Production values now live in the cloud-host-gitops repo (platform/values-abrban.yaml).
# Fallback: example template in this repo for bootstrap / local helm.
VALUES="${VALUES:-${ROOT}/../cloud-host-gitops/platform/values-abrban.yaml}"
if [[ ! -f "${VALUES}" ]]; then
VALUES="${ROOT}/gitops/platform/values-abrban.example.yaml"
fi
TAG="${TAG:-}"
if [[ ! -f "${VALUES}" ]]; then
echo "ERROR: values file not found." >&2
echo "Clone the GitOps repo next to this one, or pass VALUES=/path/to/values-abrban.yaml:" >&2
echo " git clone https://git.abrban.com/abrban/cloud-host-gitops.git" >&2
echo "Or copy gitops/platform/values-abrban.example.yaml to your gitops repo." >&2
exit 1
fi
if [[ -z "${TAG}" ]]; then
TAG="$(grep -E '^\s+tag:' "${VALUES}" | head -1 | sed 's/.*tag: *"\?\([^"]*\)"\?.*/\1/')"
fi
+8
View File
@@ -0,0 +1,8 @@
{
"name": "cloudhost-pdf-scripts",
"private": true,
"type": "module",
"dependencies": {
"puppeteer-core": "^24.0.0"
}
}
+5 -6
View File
@@ -40,7 +40,7 @@ spec:
mountPath: /workspace
containers:
- name: kaniko
image: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2
image: registry.abrban.com/abrban/kaniko-executor:v1.23.2
command:
- sh
- -ec
@@ -70,9 +70,8 @@ EOF
echo "==> Waiting for build job..."
kubectl -n "${NAMESPACE}" wait --for=condition=complete "job/${JOB_NAME}" --timeout=45m
echo "==> Updating values-abrban.yaml tag to ${IMAGE_TAG}"
sed -i.bak "s|tag: \".*\"|tag: \"${IMAGE_TAG}\"|g" "${ROOT}/gitops/platform/values-abrban.yaml"
rm -f "${ROOT}/gitops/platform/values-abrban.yaml.bak"
echo "==> Build complete. Deploy with:"
echo "==> Build complete (tag=${IMAGE_TAG})."
echo "To deploy via GitOps, commit the tag in the cloud-host-gitops repo:"
echo " platform/values-abrban.yaml → images.backend.tag / images.frontend.tag = \"${IMAGE_TAG}\""
echo "Or for a direct Helm deploy (bootstrap only):"
echo " TAG=${IMAGE_TAG} ./scripts/gitops-deploy.sh"