Compare commits

4 Commits

Author SHA1 Message Date
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
70 changed files with 5911 additions and 489 deletions
+66 -1
View File
@@ -71,6 +71,63 @@ jobs:
ENDSCRIPT
chmod +x wait_for_job.sh
- name: Run backend tests (Job)
shell: sh
run: |
JOB_NAME="test-be-$(echo $IMAGE_TAG | tr '.:' '-' | cut -c1-50)"
cat <<ENDJOB | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: ${JOB_NAME}
namespace: ${BUILD_NS}
spec:
ttlSecondsAfterFinished: 3600
backoffLimit: 0
template:
spec:
restartPolicy: Never
imagePullSecrets:
- name: registry-pull-secret
containers:
- name: test
image: ${PULL_REGISTRY}/${PROJECT}/node:24-alpine
envFrom:
- secretRef:
name: registry-egress-proxy
command:
- sh
- -c
- |
apk add --no-cache git &&
git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace &&
cd /workspace/backend &&
npm ci --legacy-peer-deps &&
npm run test -- --ci --runInBand
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: "2", memory: 3Gi }
ENDJOB
echo "Waiting for backend test job: ${JOB_NAME}"
# Reuse the waiter but read logs from the "test" container on failure
DEADLINE=$(( $(date +%s) + 1800 ))
while :; do
CONDS="$(kubectl -n ${BUILD_NS} get job/${JOB_NAME} -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}' 2>/dev/null)"
case "$CONDS" in
*Complete=True*) echo "Tests passed"; break ;;
*Failed=True*)
echo "Tests FAILED — logs:"
kubectl -n ${BUILD_NS} logs job/${JOB_NAME} -c test --tail=200 || true
exit 1 ;;
esac
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
echo "Timed out waiting for tests — logs:"
kubectl -n ${BUILD_NS} logs job/${JOB_NAME} -c test --tail=200 || true
exit 1
fi
sleep 15
done
- name: Build backend image (Kaniko Job)
shell: sh
run: |
@@ -219,5 +276,13 @@ jobs:
git add "${VALUES}"
if ! git diff --cached --quiet; then
git commit -m "ci: deploy platform ${IMAGE_TAG}"
git push origin HEAD:main
# Retry with rebase — another pipeline may have pushed meanwhile.
for attempt in 1 2 3; do
if git push origin HEAD:main; then
break
fi
echo "Push rejected (attempt ${attempt}) — rebasing on latest main"
git pull --rebase origin main
[ "$attempt" = "3" ] && { echo "Giving up after 3 attempts"; exit 1; }
done
fi
+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)
+15 -4
View File
@@ -14,6 +14,8 @@ custom `wp-content` entrypoint).
> 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.
---
@@ -111,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)
@@ -211,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
@@ -279,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` / — |
+18 -5
View File
@@ -2,6 +2,8 @@
این مستند جریان کامل Build و Deploy پلتفرم را توضیح می‌دهد: از Push شدن کد روی `main` تا استقرار خودکار روی Kubernetes.
> **استقرار از صفر روی سرور جدید:** [`RUNBOOK-DEPLOY.fa.md`](RUNBOOK-DEPLOY.fa.md) — شامل جدول متغیرها، seal کردن Secretها، logging stack، greenfield reset، و چک‌لیست سلامت.
---
## معماری و جریان کلی
@@ -26,9 +28,10 @@ flowchart TD
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. برای هر ایمیج (backend و frontend) یک Kaniko Job در namespace `cloudhost-builds` ساخته می‌شود که کد را کلون، ایمیج را build و به Harbor push می‌کند.
5. بعد از موفقیت هر دو Build، همان Runner ریپوی **`cloud-host-gitops`** را کلون می‌کند، مقدار `images.backend.tag` و `images.frontend.tag` را در `platform/values-abrban.yaml` عوض و commit/push می‌کند.
6. Argo CD (Application به نام `abrban-platform` با sync خودکار) تغییر را تشخیص می‌دهد و نسخهٔ جدید را در namespace `cloudhost` مستقر می‌کند.
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 نمی‌شود.
@@ -249,9 +252,16 @@ git push origin main
| `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** |
| `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 را عوض می‌کند).
چارت 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
@@ -305,4 +315,7 @@ Secretهایی که هنوز دستی‌اند (خارج از چرخهٔ CI): `a
| دیدن تگ‌های موجود در 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
@@ -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: ""
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 (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
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 (
'active',
'expired',
'revoked'
);
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';
@@ -113,3 +113,14 @@ 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 }}
{{- 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:
@@ -89,6 +100,7 @@ spec:
key: cluster-kubeconfig-key
- name: FRONTEND_URL
value: {{ include "cloudhost-platform.corsOrigins" . | quote }}
{{- 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:
@@ -22,6 +22,10 @@ Secret out-of-band (e.g. SealedSecret in the gitops repo).
{{- 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:
@@ -35,4 +39,5 @@ stringData:
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
+50 -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,16 @@ images:
tag: "1.0.0"
pullPolicy: IfNotPresent
# Kaniko job images — defaults pull from Harbor proxy-cache.
# Override any line for a different registry/tag.
build:
images:
kaniko: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2
alpine: registry.abrban.com/proxy-dockerhub/library/alpine:3.19
alpineGit: registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0
# Prefix for Docker Hub images in generated user-app Dockerfiles (node, php, …)
baseImageRegistry: registry.abrban.com/proxy-dockerhub/library
postgres:
enabled: true
database: cloudhost
@@ -32,12 +45,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,7 +81,13 @@ backend:
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
resources: {}
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
extraEnv: {}
env:
NODE_ENV: production
@@ -73,7 +111,13 @@ 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:
@@ -117,6 +161,7 @@ monitoring:
backups:
postgres:
enabled: false
enabled: true
schedule: "0 3 * * *"
storageSize: 10Gi
retentionDays: 7
+10 -12
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
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 (
'database',
'redis',
'rabbitmq_amqp',
'rabbitmq_management'
);
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 (
'active',
'expired',
'revoked'
);
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,
+73 -32
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,21 +206,23 @@ export class BillingService {
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
wallet.balance = Number(wallet.balance) + amount;
await this.walletRepo.save(wallet);
const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
wallet.balance = Number(wallet.balance) + amount;
await em.getRepository(Wallet).save(wallet);
const tx = this.txRepo.create({
walletId: wallet.id,
type: TransactionType.CHARGE,
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id,
type: TransactionType.CHARGE,
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
});
return em.getRepository(WalletTransaction).save(tx);
});
const saved = await this.txRepo.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,26 +235,28 @@ export class BillingService {
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
if (Number(wallet.balance) < amount) {
throw new BadRequestException('Insufficient wallet balance');
}
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);
wallet.balance = Number(wallet.balance) - amount;
await em.getRepository(Wallet).save(wallet);
const tx = this.txRepo.create({
walletId: wallet.id,
type: TransactionType.DEDUCTION,
amount,
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id,
type: TransactionType.DEDUCTION,
amount,
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
});
return em.getRepository(WalletTransaction).save(tx);
});
const saved = await this.txRepo.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();
}
+262 -76
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,83 @@ 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);
}
/**
* 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 +151,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 +213,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 +248,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 +294,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 +308,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 +325,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 +383,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}`);
@@ -320,7 +426,7 @@ export class BuildService {
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 +495,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) {
@@ -446,25 +554,33 @@ export class BuildService {
// Add init container that unzips the source code from PVC
initContainers.push({
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 &&
reject_unsafe_path() {
case "$1" in ..|../*|*/../*|/*) echo "ERROR: unsafe archive path: $1" && exit 1;; esac
} &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
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 &&
@@ -493,36 +609,60 @@ export class BuildService {
],
});
} 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({
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/Dockerfile
echo ">>> Workspace contents:"
ls -la /workspace-out/source/
`,
],
@@ -545,7 +685,7 @@ 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',
@@ -581,13 +721,19 @@ export class BuildService {
containers: [
{
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',
},
},
},
],
@@ -666,6 +812,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 +937,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 +956,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' }],
@@ -1012,10 +1171,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 +1192,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 +1237,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 +1247,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 +1334,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 +1452,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 +1468,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 +1483,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 +1505,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 +1573,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 +1581,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 +1634,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
+24
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,29 @@ 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/proxy-dockerhub/library')
.trim()
.replace(/\/+$/, ''),
/** Full image refs for Kaniko jobs — override via Helm values or env. */
images: {
kaniko:
process.env.KANIKO_IMAGE ||
'registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2',
alpine: (process.env.BUILD_ALPINE_IMAGE || 'registry.abrban.com/proxy-dockerhub/library/alpine:3.19').trim(),
alpineGit: (process.env.BUILD_ALPINE_GIT_IMAGE || 'registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0').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', () => {
+229 -207
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 },
],
resources: {
requests: { cpu: '100m', memory: '64Mi' },
limits: { cpu: '500m', memory: '256Mi' },
},
},
],
volumes: [
{
name: 'wp-content',
persistentVolumeClaim: { claimName: pvcName },
},
{ name: 'archive', secret: { secretName } },
],
containers: [
{
name: 'restore',
image: 'alpine:3.19',
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'wp-content', mountPath: '/wp-content' }],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
},
},
],
volumes: [{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } }],
restartPolicy: 'Never',
},
};
try {
await batchApi.createNamespacedJob({ namespace, body: job });
} catch (e: any) {
try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace });
} catch {}
return {
success: false,
logs: `Failed to create restore job: ${e.message}`,
};
}
const tmpArchive = path.join(os.tmpdir(), `wp-content-restore-${ts}.tar.gz`);
const tmpKubeconfig = path.join(os.tmpdir(), `kubeconfig-wprestore-${ts}.yaml`);
// 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;
}
} 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 || '';
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));
}
} catch {}
try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace });
} catch {}
await execFileAsync(
'kubectl',
['--kubeconfig', tmpKubeconfig, 'cp', tmpArchive, `${namespace}/${helperPodName}:/tmp/wp-content.tar.gz`, '--retries', '3'],
{ maxBuffer: 50 * 1024 * 1024, timeout: 600_000 },
);
return {
success: succeeded && !failed,
logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out'),
};
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 {
fs.unlinkSync(tmpArchive);
} catch {}
try {
fs.unlinkSync(tmpKubeconfig);
} catch {}
try {
await coreApi.deleteNamespacedPod({ name: helperPodName, namespace });
} catch {}
}
}
// ─── 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 ') : '*';
+28 -18
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);
const hourlyAmount = cost.hourly;
if (hourlyAmount <= 0) return false;
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
}
// Check wallet balance
const { balance } = await this.billingService.getBalance(app.userId);
if (balance < hourlyAmount) return false;
const cost = await this.billingService.calculateCostForApp(locked);
const hourlyAmount = cost.hourly;
if (hourlyAmount <= 0) return false;
// Deduct and renew
await this.billingService.deductWallet(
app.userId,
hourlyAmount,
`Auto-renew hourly: ${app.name}`,
app.id,
);
const { balance } = await this.billingService.getBalance(locked.userId);
if (balance < hourlyAmount) return false;
app.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
app.lifecycleStatus = AppLifecycleStatus.ACTIVE;
await this.appRepo.save(app);
await this.billingService.deductWallet(
locked.userId,
hourlyAmount,
`Auto-renew hourly: ${locked.name}`,
locked.id,
);
this.logger.log(`Auto-renewed hourly plan for ${app.name} — deducted ${hourlyAmount} Toman`);
return true;
locked.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
locked.lifecycleStatus = AppLifecycleStatus.ACTIVE;
await em.save(locked);
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;
+21 -12
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,22 +47,31 @@ 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
const config = new DocumentBuilder()
.setTitle('CloudHost PaaS API')
.setDescription('Self-service PaaS platform API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// 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')
.setVersion('1.0')
.addBearerAuth()
.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}`);
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
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);
}
+27 -23
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,30 +209,33 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
purpose: VerificationPurpose,
code: string,
): Promise<VerificationCode> {
const record = await this.codeRepo.findOne({
where: { userId, purpose, consumedAt: IsNull() },
order: { createdAt: 'DESC' },
});
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()) {
throw new BadRequestException('No active code — request a new one');
}
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
record.consumedAt = new Date();
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 em.save(record);
throw new BadRequestException('Invalid code');
}
if (!record || record.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('No active code — request a new one');
}
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
record.consumedAt = new Date();
await this.codeRepo.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);
throw new BadRequestException('Invalid code');
}
record.consumedAt = new Date();
await this.codeRepo.save(record);
return 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
@@ -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>;
+7 -4
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 |
@@ -84,8 +86,9 @@ kubectl apply -f gitops/argocd/application-platform.yaml
Gitea Actions: [.gitea/workflows/build-deploy.yaml](../.gitea/workflows/build-deploy.yaml)
Push به `main` → Kaniko → push به `abrban/` → کامیت tag در ریپوی [cloud-host-gitops](https://git.abrban.com/abrban/cloud-host-gitops) → ArgoCD sync.
Push به `main` **تست Jest** Kaniko → push به `abrban/` → کامیت tag در ریپوی [cloud-host-gitops](https://git.abrban.com/abrban/cloud-host-gitops) → ArgoCD sync.
- مقادیر Production در ریپوی جدا `abrban/cloud-host-gitops` است (`platform/values-abrban.yaml`)؛ Application به‌صورت multi-source تعریف شده.
- Secretهای CI به‌صورت SealedSecret در همان ریپو هستند (کنترلر در `kube-system`، values در `gitops/sealed-secrets/values.yaml`).
- مستند کامل: [RUNBOOK-CICD.fa.md](../RUNBOOK-CICD.fa.md)
- **استقرار اولیه از صفر:** [`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)
+154
View File
@@ -0,0 +1,154 @@
# 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
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/proxy-gcr/kaniko-project/executor:v1.23.2
alpine: registry.abrban.com/proxy-dockerhub/library/alpine:3.19
alpineGit: registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0
baseImageRegistry: registry.abrban.com/proxy-dockerhub/library
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"
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
@@ -0,0 +1,30 @@
# 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
#
# 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' \
# --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
+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}`);
+6 -1
View File
@@ -6,13 +6,18 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NAMESPACE="${NAMESPACE:-cloudhost}"
RELEASE="${RELEASE:-cloudhost}"
# 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: ${VALUES}" >&2
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
+8
View File
@@ -0,0 +1,8 @@
{
"name": "cloudhost-pdf-scripts",
"private": true,
"type": "module",
"dependencies": {
"puppeteer-core": "^24.0.0"
}
}