Compare commits
4 Commits
34c110be6a
...
d3bbc0c0a0
| Author | SHA1 | Date | |
|---|---|---|---|
| d3bbc0c0a0 | |||
| 8163665c86 | |||
| 6d9cd89cc5 | |||
| 22359be40e |
@@ -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
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 چیست
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ') : '*';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -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>/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>/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>
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "cloudhost-pdf-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"puppeteer-core": "^24.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user