Harden platform security, reliability, and CI after full audit.
Close deployment IDOR and gate stub payment endpoints, add production secret validation, health probes, Redis-backed build progress, GitHub Actions CI, expanded tests, billing/k8s refactors, and ops runbooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint:check
|
||||
- run: npm test -- --passWithNoTests
|
||||
- run: npm run test:e2e
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
|
||||
helm:
|
||||
name: Helm Charts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.15.4
|
||||
- run: helm lint backend/helm/cloudhost-platform
|
||||
- run: helm lint backend/helm/cloudhost-app
|
||||
- run: helm lint backend/helm/cloudhost-logging
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CloudHost
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -43,6 +43,7 @@ custom `wp-content` entrypoint).
|
||||
|
||||
> 📖 See **[ARCHITECTURE.md](ARCHITECTURE.md)** for detailed system design.
|
||||
> 🔼 See [UPGRADE.en.md](UPGRADE.en.md) ([فارسی](UPGRADE.md)) for dependency-upgrade notes.
|
||||
> 📋 See [RUNBOOK.en.md](RUNBOOK.en.md) ([فارسی](RUNBOOK.fa.md)) for operations.
|
||||
|
||||
---
|
||||
|
||||
@@ -298,7 +299,7 @@ Tokens: JWT access (`JWT_EXPIRES_IN`, default 1h) + refresh (7d).
|
||||
## API
|
||||
|
||||
All endpoints are prefixed with `/api/v1`. Interactive Swagger docs at
|
||||
`http://localhost:4000/docs`. Major route groups: `auth` (OTP request/verify, login,
|
||||
`http://localhost:4000/api/docs`. Major route groups: `auth` (OTP request/verify, login,
|
||||
refresh), `applications`, `deployments`, `clusters`, `billing` (wallet, invoices,
|
||||
transactions, pricing), `snapshots`, `tickets`, `users`, `admin`, `notifications`.
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# CloudHost — Operations Runbook (English)
|
||||
|
||||
Short operational guide. For architecture details see [ARCHITECTURE.md](ARCHITECTURE.md). For Persian production deploy steps see [RUNBOOK.fa.md](RUNBOOK.fa.md).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend:** Next.js 16 (`/fa-IR`, `/en-US` routes)
|
||||
- **Backend:** NestJS 11 (`/api/v1`, Swagger at `/api/docs`)
|
||||
- **Data:** PostgreSQL 16, Redis 7
|
||||
- **Build:** Kaniko in-cluster (`cloudhost-builds` namespace)
|
||||
- **Deploy:** Helm charts (`cloudhost-platform`, `cloudhost-app`, `cloudhost-logging`)
|
||||
|
||||
## Health checks
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /api/v1/health` | Liveness |
|
||||
| `GET /api/v1/ready` | Readiness (DB ping) |
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
cd backend && cp .env.example .env && npm run start:dev
|
||||
cd frontend && cp .env.local.example .env.local && npm run dev
|
||||
```
|
||||
|
||||
## SQL migrations
|
||||
|
||||
1. Add file under `backend/migrations/`
|
||||
2. Run `cd backend && npm run sync:migrations` (copies into Helm chart)
|
||||
3. Helm post-install Job applies migrations on upgrade
|
||||
|
||||
## Build namespace bootstrap
|
||||
|
||||
```bash
|
||||
kubectl apply -f backend/k8s/builds/cloudhost-builds-bootstrap.yaml
|
||||
```
|
||||
|
||||
Configure `REGISTRY_URL`, `BUILD_NAMESPACE`, and `CLUSTER_KUBECONFIG_KEY` in backend env.
|
||||
|
||||
## Backups (optional Helm)
|
||||
|
||||
Enable in `values.yaml`:
|
||||
|
||||
```yaml
|
||||
backups:
|
||||
postgres:
|
||||
enabled: true
|
||||
schedule: "0 3 * * *"
|
||||
storageSize: 10Gi
|
||||
```
|
||||
|
||||
Restore: `gunzip -c backup.sql.gz | psql` against the control-plane database.
|
||||
|
||||
## Monitoring (optional)
|
||||
|
||||
Enable Prometheus ServiceMonitor:
|
||||
|
||||
```yaml
|
||||
monitoring:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Requires `kube-prometheus-stack` or compatible Prometheus Operator in the cluster.
|
||||
|
||||
## CI
|
||||
|
||||
GitHub Actions runs backend/frontend tests, typecheck, build, and `helm lint` on push/PR.
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
# CloudHost — راهنمای معماری و اجرای وبسایت (Runbook)
|
||||
|
||||
این سند دو بخش دارد:
|
||||
1. **اپلیکیشن چطور کار میکند** — معماری و جریانها.
|
||||
2. **اجرای وبسایت، مرحلهبهمرحله** — هم برای توسعهی محلی، هم برای استقرار (deploy) روی کلاستر k3s سرور (abrban) بههمراه راهحلهای مخصوص شبکهی ایران.
|
||||
|
||||
> اصطلاحها: «پنل» = اپ احرازشده (`panel.abrban.com`)، «لندینگ» = صفحهی معرفی (`abrban.com`)، «اپ کاربر» = اپلیکیشنی که مشتری روی CloudHost دیپلوی میکند.
|
||||
|
||||
> **بهروزرسانی ۲۰۲۶:** pipeline بیلد فعلی از **Kaniko** + Dockerfileهای نگهداریشده توسط پلتفرم استفاده میکند (نه Nixpacks/MinIO). آرشیو سورس روی دیسک/PVC آپلود میشود. manifest بوتاسترپ namespace بیلد: [`backend/k8s/builds/cloudhost-builds-bootstrap.yaml`](backend/k8s/builds/cloudhost-builds-bootstrap.yaml).
|
||||
|
||||
---
|
||||
|
||||
### ۱.۱ CloudHost چیست
|
||||
یک **PaaS خودسرویس** برای بازار ایران: کاربر کد/ریپوی خودش را میدهد و CloudHost آن را build و روی Kubernetes اجرا میکند، با مدیریت دامنه، دیتابیس، لاگ، فاکتور و کیف پول.
|
||||
|
||||
### ۱.۲ اجزای اصلی
|
||||
|
||||
| جزء | تکنولوژی | نقش |
|
||||
|---|---|---|
|
||||
| **Frontend** | Next.js (App Router, SSR) | لندینگ + پنل کاربری/ادمین |
|
||||
| **Backend** | NestJS (REST `/api/v1`) | منطق کسبوکار، ساخت اپ، احراز هویت |
|
||||
| **Postgres** | postgres:16 | دیتابیس اصلی (کاربر، اپ، فاکتور، …) |
|
||||
| **Redis** | redis:7 | کش، صف Bull (مهاجرت اپ، دسترسی موقت)، پیشرفت بیلد |
|
||||
| **Registry داخلی** | registry:2 | ایمیجهای buildشده |
|
||||
| **Build pipeline** | Kaniko | تبدیل سورس به ایمیج Docker داخل کلاستر (بدون Docker daemon) |
|
||||
| **Kubernetes** | k3s (تکنود) | اجرای همهی موارد بالا + اپهای کاربر |
|
||||
|
||||
### ۱.۳ دامنهها (همه روی `78.157.39.52`، HTTPS با wildcard cert)
|
||||
- `abrban.com` → لندینگ
|
||||
- `panel.abrban.com` → پنل احرازشده
|
||||
- `api.abrban.com` → بکاند
|
||||
- `registry.abrban.com` → رجیستری داخلی (pull توسط kubelet)
|
||||
- `apps.abrban.com` → دامنهی پیشفرض اپهای کاربر
|
||||
|
||||
### ۱.۴ جریان احراز هویت
|
||||
- ورود مبتنی بر **موبایل + OTP** (پیامک از طریق MizbanSMS) یا رمز عبور.
|
||||
- توکن **JWT** (access ~15m، refresh ~7d).
|
||||
- `JwtStrategy` در هر درخواست نقش و فعالبودن کاربر را **از دیتابیس** میخواند (نه از توکن) تا تغییر نقش/غیرفعالسازی بلافاصله اثر کند.
|
||||
|
||||
### ۱.۵ جریان دیپلویِ «اپ کاربر» (مهمترین بخش)
|
||||
وقتی کاربر یک اپ را build/redeploy میکند:
|
||||
|
||||
```
|
||||
کاربر (پنل)
|
||||
│ آپلود zip ──────────────► MinIO (bucket: app-sources) ┐
|
||||
│ یا git URL │ منبع سورس
|
||||
▼ │
|
||||
Backend: یک job در صف Bull («app-deploy») میگذارد │
|
||||
▼ │
|
||||
ساخت یک Kubernetes Job در namespace «cloudhost-builds»: │
|
||||
1) init: fetch-source (دانلود از MinIO) یا git-clone ◄───────┘
|
||||
2) init: nixpacks-prepare
|
||||
• اگر سورس Dockerfile دارد → همان (BYO)
|
||||
• وگرنه → با Nixpacks یک Dockerfile میسازد
|
||||
3) container: Kaniko → build ایمیج → push به registry داخلی
|
||||
▼
|
||||
(غیرمسدودکننده) اسکن Trivy → خلاصهی آسیبپذیری
|
||||
▼
|
||||
Backend با Helm، اپ را روی کلاستر بالا میآورد (Deployment + Service + Ingress)
|
||||
▼
|
||||
GC رجیستری: روزانه فقط N نسخهی آخر هر اپ را نگه میدارد
|
||||
```
|
||||
|
||||
- پیشرفت بیلد در Redis نگهداری میشود؛ فرانت هر ۱.۵ ثانیه `build-progress`/`build-logs` را poll میکند.
|
||||
- توکن git در یک Secret موقتِ هر بیلد مینشیند و در `finally` پاک میشود (در manifest درج نمیشود).
|
||||
|
||||
---
|
||||
|
||||
## بخش ۲ — اجرای محلی (Local Dev)
|
||||
|
||||
پیشنیاز: Node 20، Docker، Docker Compose.
|
||||
|
||||
```bash
|
||||
# ۱) دیتابیس و Redis
|
||||
docker compose up -d # از docker-compose.yml ریشهی پروژه
|
||||
|
||||
# ۲) بکاند
|
||||
cd backend
|
||||
cp .env.example .env # مقادیر را پر کن (DB، JWT، SMS، …)
|
||||
npm install
|
||||
npm run start:dev # روی http://localhost:4000 (پیشوند /api/v1)
|
||||
|
||||
# ۳) فرانت
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run dev # روی http://localhost:3000
|
||||
```
|
||||
|
||||
- در حالت dev، TypeORM `synchronize` روشن است و جدولها خودکار ساخته میشوند.
|
||||
- `NEXT_PUBLIC_API_URL` فرانت باید به آدرس بکاند اشاره کند.
|
||||
|
||||
---
|
||||
|
||||
## بخش ۳ — استقرار روی کلاستر (abrban / k3s)
|
||||
|
||||
> این بخش فرض میکند کلاستر k3s و wildcard cert از قبل آمادهاند. context کوبه: `default`.
|
||||
|
||||
### ۳.۰ ثابتهای محیط
|
||||
| | مقدار |
|
||||
|---|---|
|
||||
| namespace اپ | `cloudhost` |
|
||||
| namespace بیلد | `cloudhost-builds` |
|
||||
| Helm release | `cloudhost` |
|
||||
| چارت | `backend/helm/cloudhost-platform` |
|
||||
| values | `/tmp/abrban/values-abrban.yaml` |
|
||||
| رجیستری (push داخلی) | `registry.cloudhost.svc.cluster.local:5000` (HTTP, insecure) |
|
||||
| رجیستری (pull توسط kubelet) | `registry.abrban.com` (HTTPS, wildcard cert) — همان storage |
|
||||
| پروکسی build | `http://builder:<pw>@45.129.38.203:9911` |
|
||||
|
||||
### ۳.۱ نکات شبکهی ایران (چرا کارها اینشکلیاند)
|
||||
- کلاستر به **Let's Encrypt، github، docker.io، ghcr.io، gcr.io** مستقیم نمیرسد (یا خیلی کند).
|
||||
- **gTLS/cert**: دستی، secret `abrban-wildcard-tls` (نه cert-manager).
|
||||
- **npm**: از `registry.npmmirror.com` مستقیم (نه پروکسی).
|
||||
- **ایمیجهای پایه**: اول داخل رجیستری داخلی **mirror** میشوند، بعد استفاده.
|
||||
- **دانلودهای build (apk/nix/pip/…)**: از طریق پروکسی بالا.
|
||||
|
||||
### ۳.۲ گامهای استقرار
|
||||
|
||||
**گام ۱ — بررسی دسترسی کلاستر**
|
||||
```bash
|
||||
kubectl config current-context # باید default باشد
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
**گام ۲ — mirror کردن ایمیجهای پایه به رجیستری داخلی**
|
||||
ایمیجهایی که کلاستر مستقیم نمیتواند pull کند را با یک Job داخل کلاستر کپی کن.
|
||||
- برای ایمیجهای **کوچک**: `crane copy <src> registry.cloudhost.svc.cluster.local:5000/<dst> --insecure`
|
||||
- برای ایمیجهای **بزرگ** (مثل nixpacks): از **skopeo** استفاده کن — چون بلاب را با PUT یکجا آپلود میکند و گیر `PROTOCOL_ERROR` آپلود تکهای crane را ندارد:
|
||||
```
|
||||
skopeo copy --override-os linux --override-arch amd64 --dest-tls-verify=false \
|
||||
docker://<src> docker://registry.cloudhost.svc.cluster.local:5000/<dst>
|
||||
```
|
||||
(با `REGISTRY_AUTH_FILE` از secret `kaniko-docker-config` و env پروکسی.)
|
||||
|
||||
ایمیجهای لازم: `minio/minio`, `railwayapp/nixpacks`, `library/alpine:3.19`, `library/postgres`, `library/redis`, و base ای که Nixpacks تولید میکند.
|
||||
|
||||
**گام ۳ — MinIO (ذخیرهی سورس اپها)**
|
||||
بهطور خودکار فقط هنگام **ثبت کلاستر جدید** ساخته میشود؛ روی کلاستر موجود دستی بساز: secret `minio-credentials` (`accesskey`/`secretkey`) + PVC ۲۰Gi + Deployment (`registry.abrban.com/minio/minio:latest`) + Service، همه در `cloudhost-builds`. کردنشال پیشفرض با config بکاند میخواند.
|
||||
|
||||
**گام ۴ — pull-secret برای namespace بیلد**
|
||||
```bash
|
||||
# کپی pull-secret رجیستری به ns بیلد
|
||||
kubectl get secret registry-pull-secret -n cloudhost -o json \
|
||||
| jq '.metadata.namespace="cloudhost-builds" | del(.metadata.uid,.metadata.resourceVersion,.metadata.creationTimestamp)' \
|
||||
| kubectl apply -f -
|
||||
# وصل به هر دو ServiceAccount که pod بیلد ممکن است از آنها استفاده کند
|
||||
kubectl patch sa default -n cloudhost-builds -p '{"imagePullSecrets":[{"name":"registry-pull-secret"}]}'
|
||||
kubectl patch sa kaniko-builder -n cloudhost-builds -p '{"imagePullSecrets":[{"name":"registry-pull-secret"}]}'
|
||||
```
|
||||
> ⚠️ `kaniko-builder` حتماً لازم است: pod بیلد با همین SA اجرا میشود و init container نیکسپکس ایمیجش را از `registry.abrban.com` میکشد.
|
||||
|
||||
**گام ۵ — build ایمیجهای frontend/backend (Kaniko)**
|
||||
سورس را در PVC بیلد (`build-src`) از طریق pod `srcsync` قرار بده، سپس Jobهای Kaniko را اجرا کن.
|
||||
```bash
|
||||
# سینک سورس (از working tree؛ tsbuildinfo و dist را حذف کن!)
|
||||
cd <repo>
|
||||
tar czf - --exclude=node_modules --exclude=.next --exclude=.git frontend \
|
||||
| kubectl exec -i srcsync -n cloudhost -- sh -c 'rm -rf /workspace/frontend && tar xzf - -C /workspace'
|
||||
rm -f backend/tsconfig.tsbuildinfo # ← مهم
|
||||
tar czf - --exclude=node_modules --exclude=dist --exclude=.git backend \
|
||||
| kubectl exec -i srcsync -n cloudhost -- sh -c 'rm -rf /workspace/backend && tar xzf - -C /workspace'
|
||||
|
||||
# build (manifestهای kaniko-*.yaml: registry-mirror + proxy + npmmirror)
|
||||
kubectl apply -f /tmp/abrban/kaniko-frontend-<tag>.yaml
|
||||
kubectl apply -f /tmp/abrban/kaniko-backend-<tag>.yaml
|
||||
```
|
||||
|
||||
**گام ۶ — استقرار با Helm**
|
||||
```bash
|
||||
helm upgrade cloudhost backend/helm/cloudhost-platform \
|
||||
-n cloudhost -f /tmp/abrban/values-abrban.yaml \
|
||||
--set images.backend.tag=<tag> \
|
||||
--set images.frontend.tag=<tag> \
|
||||
--set migrations.enabled=false # ← مهاجرتها روی DB زنده تداخل دارند
|
||||
```
|
||||
> بدون `--wait` اجرا کن (وگرنه بهخاطر کندیِ pull، status اشتباهاً `failed` میشود درحالیکه rollout موفق است).
|
||||
|
||||
**گام ۷ — bootstrap اسکیمای دیتابیس (فقط روی DB تازه)**
|
||||
در پروداکشن `synchronize` خاموش است. روی DB کاملاً تازه: موقتاً `NODE_ENV=development` کن تا synchronize جدولها را بسازد و pricing خودش seed شود، بعد به `production` برگردان. روی DB موجود، فقط مهاجرتهای idempotent جدید را با یک Job جدا اعمال کن (نه helm hook).
|
||||
|
||||
**گام ۸ — تأیید**
|
||||
```bash
|
||||
kubectl get deploy -n cloudhost # backend/frontend 1/1
|
||||
helm status cloudhost -n cloudhost # STATUS: deployed
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://panel.abrban.com # 307
|
||||
curl -s -X POST https://api.abrban.com/api/v1/auth/otp/request \
|
||||
-H 'Content-Type: application/json' -d '{"phone":"09xxxxxxxxx"}' # {"sent":true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## بخش ۴ — envهای کلیدی build pipeline (روی بکاند)
|
||||
اینها در `backend.env` فایل values ست میشوند:
|
||||
|
||||
| env | مقدار/توضیح |
|
||||
|---|---|
|
||||
| `MINIO_SECRET_KEY` | کلید MinIO (هماهنگ با secret) |
|
||||
| `NIXPACKS_IMAGE` | `registry.abrban.com/railwayapp/nixpacks:latest` (mirror) |
|
||||
| `NIXPACKS_BUILD_ENV` | `NPM_CONFIG_REGISTRY=https://registry.npmmirror.com` |
|
||||
| `BUILD_HTTP_PROXY` | پروکسی build (تزریق به Kaniko + init containerها) |
|
||||
| `BUILD_REGISTRY_MIRROR` | `registry.cloudhost.svc.cluster.local:5000` (pull پایه از mirror) |
|
||||
| `BUILD_SCAN_ENABLED` | `false` تا وقتی ایمیج Trivy mirror شود |
|
||||
| `SMS_PROVIDER` + `MIZBANSMS_*` | بدون اینها ارسال OTP خطای 503 میدهد |
|
||||
|
||||
---
|
||||
|
||||
## بخش ۵ — عیبیابی رایج
|
||||
|
||||
| نشانه | علت | راهحل |
|
||||
|---|---|---|
|
||||
| backend کرش: `Cannot find module '/app/dist/main.js'` | `tsconfig.tsbuildinfo` کهنه در سورس → tsc فایلها را دوباره emit نمیکند | قبل از build، `tsconfig.tsbuildinfo` را حذف کن |
|
||||
| init container بیلد: `no basic auth credentials` | SA `kaniko-builder` بدون pull-secret | گام ۴ را اجرا کن |
|
||||
| pull ایمیج: `not found` با اینکه push شده | crane در آپلود تکهایِ بلاب بزرگ شکست خورده (tag ناقص) | با **skopeo** دوباره mirror کن |
|
||||
| pull از docker.io: `TLS handshake timeout` | docker.io از کلاستر بسته است | ایمیج را mirror کن |
|
||||
| `nixpacks: not found` در init | باینری نیکسپکس روی PATH پیشفرض نیست | command را با مسیر/ENTRYPOINT درست صدا بزن |
|
||||
| helm: `no template "...namespace"` یا `nil pointer redis.enabled` | فایلهای چارت (`_helpers.tpl`/`values.yaml`) از `/tmp` پاک شدهاند | از سورس اصلی بازیابی + ادیتهای deploy را دوباره اعمال کن |
|
||||
| OTP خطای 503 | env پیامک ست نیست | `SMS_PROVIDER`/`MIZBANSMS_*` را ست کن |
|
||||
|
||||
> ⚠️ پوشهی `/tmp` در macOS فایلهای قدیمیتر از ~۳ روز را پاک میکند؛ درخت کاری deploy در `/tmp` ممکن است فایل از دست بدهد — قبل از build بررسی کن.
|
||||
|
||||
---
|
||||
|
||||
## بخش ۶ — بهروزرسانی نسخه (خلاصه)
|
||||
1. تغییرات کد را در سورس اعمال کن (`tsc --noEmit` بگیر).
|
||||
2. tag جدید انتخاب کن.
|
||||
3. سورس را در `srcsync` سینک کن (با حذف `tsbuildinfo`).
|
||||
4. Job Kaniko را با tag جدید بساز.
|
||||
5. `helm upgrade ... --set images.*.tag=<tag> --set migrations.enabled=false` (بدون `--wait`).
|
||||
6. تأیید کن: podها 1/1، helm `deployed`، endpointها سالم.
|
||||
@@ -20,6 +20,13 @@ JWT_REFRESH_EXPIRES_IN=7d
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Multi-cluster: AES-256-GCM key for encrypting kubeconfigs at rest (required in production).
|
||||
# Generate with: openssl rand -hex 32
|
||||
CLUSTER_KUBECONFIG_KEY=
|
||||
|
||||
# Stub payment gateway (dev/staging only — disabled in production unless explicitly enabled)
|
||||
# PAYMENT_GATEWAY_STUB_ENABLED=true
|
||||
|
||||
# ─── OTP SMS ────────────────────────────────────────────────────────────────
|
||||
# Pick the provider. Without valid credentials, OTP codes are logged to the API
|
||||
# console in development only; in production a missing config makes OTP send fail
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts', 'test/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Mobile-first auth: phone is the login identifier, email becomes an optional
|
||||
-- contact field, plus a table of short-lived one-time SMS codes for verifying
|
||||
-- a phone (registration/login completion and number changes).
|
||||
|
||||
-- Email becomes optional (login no longer uses it). Postgres treats NULLs as
|
||||
-- distinct, so the existing UNIQUE constraint keeps working for users without one.
|
||||
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS "phoneVerified" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Unique per non-null phone (NULLs allowed for legacy email-only staff accounts).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique ON users (phone) WHERE phone IS NOT NULL;
|
||||
|
||||
-- One-time SMS verification codes (hashed).
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE verification_codes_purpose_enum AS ENUM ('login', 'change_phone');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose verification_codes_purpose_enum NOT NULL,
|
||||
destination VARCHAR NOT NULL,
|
||||
"codeHash" VARCHAR NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
"consumedAt" TIMESTAMPTZ,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS verification_codes_user_purpose_idx
|
||||
ON verification_codes ("userId", purpose);
|
||||
@@ -93,14 +93,14 @@ spec:
|
||||
mountPath: /app/uploads
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/docs
|
||||
path: /api/v1/health
|
||||
port: 4000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/docs
|
||||
path: /api/v1/ready
|
||||
port: 4000
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- if .Values.monitoring.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "cloudhost-platform.fullname" . }}-backend-metrics
|
||||
namespace: {{ include "cloudhost-platform.namespace" . }}
|
||||
labels:
|
||||
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 4000
|
||||
targetPort: 4000
|
||||
selector:
|
||||
app: {{ include "cloudhost-platform.backend.fullname" . }}
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "cloudhost-platform.fullname" . }}-backend
|
||||
namespace: {{ include "cloudhost-platform.namespace" . }}
|
||||
labels:
|
||||
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
||||
release: prometheus
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ include "cloudhost-platform.backend.fullname" . }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /api/v1/health
|
||||
interval: 30s
|
||||
{{- end }}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{- if .Values.backups.postgres.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "cloudhost-platform.fullname" . }}-postgres-backup
|
||||
namespace: {{ include "cloudhost-platform.namespace" . }}
|
||||
labels:
|
||||
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
||||
spec:
|
||||
schedule: {{ .Values.backups.postgres.schedule | quote }}
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: backup
|
||||
image: {{ .Values.images.postgres | quote }}
|
||||
env:
|
||||
- name: PGHOST
|
||||
value: {{ include "cloudhost-platform.postgres.fullname" . }}
|
||||
- name: PGPORT
|
||||
value: "5432"
|
||||
- name: PGDATABASE
|
||||
value: {{ .Values.postgres.database | quote }}
|
||||
- name: PGUSER
|
||||
value: {{ .Values.postgres.username | quote }}
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "cloudhost-platform.secretName" . }}
|
||||
key: postgres-password
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
FILE="/backup/cloudhost-${STAMP}.sql.gz"
|
||||
pg_dump | gzip > "$FILE"
|
||||
echo "Backup written to $FILE"
|
||||
volumeMounts:
|
||||
- name: backup
|
||||
mountPath: /backup
|
||||
volumes:
|
||||
- name: backup
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "cloudhost-platform.fullname" . }}-postgres-backup
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "cloudhost-platform.fullname" . }}-postgres-backup
|
||||
namespace: {{ include "cloudhost-platform.namespace" . }}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.backups.postgres.storageSize }}
|
||||
{{- end }}
|
||||
@@ -97,3 +97,12 @@ ingress:
|
||||
migrations:
|
||||
enabled: true
|
||||
image: postgres:16-alpine
|
||||
|
||||
monitoring:
|
||||
enabled: false
|
||||
|
||||
backups:
|
||||
postgres:
|
||||
enabled: false
|
||||
schedule: "0 3 * * *"
|
||||
storageSize: 10Gi
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cloudhost-builds
|
||||
labels:
|
||||
app.kubernetes.io/part-of: cloudhost
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: kaniko-builder
|
||||
namespace: cloudhost-builds
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: kaniko-builder
|
||||
namespace: cloudhost-builds
|
||||
rules:
|
||||
- apiGroups: ['']
|
||||
resources: ['pods', 'pods/log', 'secrets', 'configmaps', 'persistentvolumeclaims']
|
||||
verbs: ['create', 'get', 'list', 'watch', 'delete', 'patch', 'update']
|
||||
- apiGroups: ['batch']
|
||||
resources: ['jobs']
|
||||
verbs: ['create', 'get', 'list', 'watch', 'delete']
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: kaniko-builder
|
||||
namespace: cloudhost-builds
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: kaniko-builder
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: kaniko-builder
|
||||
namespace: cloudhost-builds
|
||||
---
|
||||
# In-cluster registry for Kaniko push + app image pull (HTTP — add TLS in production).
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: registry
|
||||
namespace: cloudhost-builds
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
app: registry
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: registry
|
||||
namespace: cloudhost-builds
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: registry
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: registry
|
||||
spec:
|
||||
containers:
|
||||
- name: registry
|
||||
image: registry:2
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
env:
|
||||
- name: REGISTRY_HTTP_ADDR
|
||||
value: ':5000'
|
||||
Generated
+29
-30
@@ -17,6 +17,7 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.26",
|
||||
"@nestjs/swagger": "^11.4.4",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bull": "^4.12.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.2.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
@@ -1347,9 +1349,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
|
||||
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
@@ -2574,6 +2576,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/throttler": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/typeorm": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz",
|
||||
@@ -4896,9 +4909,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6661,20 +6674,18 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||
"version": "5.11.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
|
||||
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.1",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
"@ioredis/commands": "1.10.0",
|
||||
"cluster-key-slot": "1.1.1",
|
||||
"debug": "4.4.3",
|
||||
"denque": "2.1.0",
|
||||
"redis-errors": "1.2.0",
|
||||
"redis-parser": "3.0.0",
|
||||
"standard-as-callback": "2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
@@ -7868,24 +7879,12 @@
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
|
||||
+19
-5
@@ -9,7 +9,9 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||
"lint:check": "eslint \"src/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -18,7 +20,8 @@
|
||||
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
|
||||
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
|
||||
"seed": "ts-node -r tsconfig-paths/register src/seed.ts",
|
||||
"sync:migrations": "node scripts/sync-helm-migrations.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.26",
|
||||
"@nestjs/swagger": "^11.4.4",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bull": "^4.12.0",
|
||||
@@ -37,6 +41,7 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.2.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
@@ -69,14 +74,23 @@
|
||||
"typescript": "^6.0.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/test-setup.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy SQL migrations from backend/migrations/ into the Helm chart ConfigMap source.
|
||||
* Run after adding or editing migration files: npm run sync:migrations
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourceDir = path.resolve(__dirname, '../migrations');
|
||||
const targetDir = path.resolve(__dirname, '../helm/cloudhost-platform/migrations');
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
console.error(`Source not found: ${sourceDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.sql')).sort();
|
||||
for (const file of files) {
|
||||
fs.copyFileSync(path.join(sourceDir, file), path.join(targetDir, file));
|
||||
}
|
||||
|
||||
// Remove stale SQL files no longer in source
|
||||
for (const existing of fs.readdirSync(targetDir)) {
|
||||
if (existing.endsWith('.sql') && !files.includes(existing)) {
|
||||
fs.unlinkSync(path.join(targetDir, existing));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Synced ${files.length} migration(s) to ${targetDir}`);
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ApplicationsModule } from './applications/applications.module';
|
||||
@@ -15,6 +17,7 @@ import { SnapshotsModule } from './snapshots/snapshots.module';
|
||||
import { LifecycleModule } from './lifecycle/lifecycle.module';
|
||||
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
@@ -54,6 +57,14 @@ import configuration from './config/configuration';
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: 'default',
|
||||
ttl: 60_000,
|
||||
limit: 120,
|
||||
},
|
||||
]),
|
||||
|
||||
// Feature modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
@@ -68,6 +79,13 @@ import configuration from './config/configuration';
|
||||
LifecycleModule,
|
||||
ApplicationMigrationsModule,
|
||||
AdminModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { DomainService } from './domain.service';
|
||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
|
||||
@@ -67,6 +68,7 @@ export class ApplicationsController {
|
||||
}
|
||||
|
||||
@Post(':id/upload')
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
@@ -7,6 +8,7 @@ import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Throttle({ default: { limit: 20, ttl: 60_000 } })
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingOpsService } from './billing-ops.service';
|
||||
import { assertStubGatewayAllowed } from './payment-gateway.util';
|
||||
import {
|
||||
ChargeWalletDto,
|
||||
InitiateInvoicePaymentDto,
|
||||
VerifyInvoiceGatewayDto,
|
||||
UpdateInvoiceStatusDto,
|
||||
} from './dto/billing.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole, InvoiceStatus, PaymentMethod } from '../common/enums';
|
||||
|
||||
@ApiTags('Billing')
|
||||
@ApiBearerAuth()
|
||||
@Controller('billing')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class BillingInvoicesController {
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingOpsService: BillingOpsService,
|
||||
) {}
|
||||
|
||||
// ─── Invoices ─────────────────────────────────────────────────────
|
||||
|
||||
@Get('invoices')
|
||||
@ApiOperation({ summary: 'List my invoices' })
|
||||
async listMyInvoices(
|
||||
@Request() req: any,
|
||||
@Query('status') status?: InvoiceStatus,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.billingService.listInvoices(req.user, {
|
||||
status,
|
||||
applicationId,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('invoices/:id')
|
||||
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
|
||||
async getInvoice(@Request() req: any, @Param('id') id: string) {
|
||||
return this.billingService.getInvoiceForUser(id, req.user);
|
||||
}
|
||||
|
||||
@Post('invoices/:id/pay/mixed')
|
||||
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
|
||||
async initiateInvoiceMixed(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: InitiateInvoicePaymentDto,
|
||||
) {
|
||||
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
|
||||
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
|
||||
return { ...result, effect };
|
||||
}
|
||||
|
||||
@Post('invoices/:id/gateway/verify')
|
||||
@ApiOperation({ summary: 'Verify invoice gateway payment' })
|
||||
async verifyInvoiceGateway(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: VerifyInvoiceGatewayDto,
|
||||
) {
|
||||
assertStubGatewayAllowed();
|
||||
const result = await this.billingService.verifyInvoiceGatewayPayment(
|
||||
id,
|
||||
req.user,
|
||||
dto.trackingCode,
|
||||
dto.amount,
|
||||
);
|
||||
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
|
||||
return { ...result, effect };
|
||||
}
|
||||
|
||||
// ─── Invoice Admin ────────────────────────────────────────────────
|
||||
|
||||
@Get('admin/invoices')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all invoices (Admin)' })
|
||||
async listAdminInvoices(
|
||||
@Request() req: any,
|
||||
@Query('status') status?: InvoiceStatus,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('paymentMethod') paymentMethod?: PaymentMethod,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.billingService.listInvoices(req.user, {
|
||||
status,
|
||||
userId,
|
||||
applicationId,
|
||||
paymentMethod,
|
||||
search,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('admin/invoices/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get invoice details (Admin)' })
|
||||
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
|
||||
return this.billingService.getInvoiceForUser(id, req.user);
|
||||
}
|
||||
|
||||
@Patch('admin/invoices/:id/status')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
|
||||
async updateAdminInvoiceStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateInvoiceStatusDto,
|
||||
) {
|
||||
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
|
||||
}
|
||||
|
||||
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||
|
||||
@Get('admin/wallets')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all wallets (Admin)' })
|
||||
async getAllWallets() {
|
||||
return this.billingService.getAllWallets();
|
||||
}
|
||||
|
||||
@Post('admin/wallets/:userId/charge')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
|
||||
async adminChargeWallet(
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: ChargeWalletDto,
|
||||
) {
|
||||
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||
import { BillingService } from './billing.service';
|
||||
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { UpgradeResourcesDto } from './dto/billing.dto';
|
||||
import {
|
||||
BillingCycle,
|
||||
InvoiceStatus,
|
||||
ProductType,
|
||||
DatabaseType,
|
||||
UserRole,
|
||||
} from '../common/enums';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BillingOpsService {
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
@Inject(forwardRef(() => AppLifecycleService))
|
||||
private readonly lifecycleService: AppLifecycleService,
|
||||
@Inject(forwardRef(() => ApplicationsService))
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
@Inject(forwardRef(() => KubernetesService))
|
||||
private readonly kubernetesService: KubernetesService,
|
||||
) {}
|
||||
|
||||
async completePaidInvoiceEffect(invoice: any) {
|
||||
if (invoice.status !== InvoiceStatus.PAID) return null;
|
||||
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
|
||||
|
||||
const action = invoice.metadata?.action;
|
||||
if (!action || !invoice.applicationId) return null;
|
||||
|
||||
if (action === 'renew' || action === 'activate') {
|
||||
const cycle = invoice.metadata?.cycle as BillingCycle;
|
||||
if (!Object.values(BillingCycle).includes(cycle)) return null;
|
||||
|
||||
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
|
||||
const result = {
|
||||
action,
|
||||
application: {
|
||||
id: activated.id,
|
||||
name: activated.name,
|
||||
lifecycleStatus: activated.lifecycleStatus,
|
||||
planExpiresAt: activated.planExpiresAt,
|
||||
billingCycle: activated.billingCycle,
|
||||
},
|
||||
};
|
||||
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === 'upgrade') {
|
||||
const app = await this.applicationsService.findOne(invoice.applicationId);
|
||||
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
|
||||
const updatedApp = await this.applicationsService.update(
|
||||
app.id,
|
||||
app.userId,
|
||||
this.buildUpgradeEntityPatch(app, resources),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
|
||||
} catch (e: any) {
|
||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
const result = {
|
||||
action,
|
||||
application: {
|
||||
id: updatedApp.id,
|
||||
name: updatedApp.name,
|
||||
cpuRequest: updatedApp.cpuRequest,
|
||||
cpuLimit: updatedApp.cpuLimit,
|
||||
memoryRequest: updatedApp.memoryRequest,
|
||||
memoryLimit: updatedApp.memoryLimit,
|
||||
replicas: updatedApp.replicas,
|
||||
},
|
||||
};
|
||||
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
||||
const pt = app.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (dto.redisResources) {
|
||||
return {
|
||||
optionalServiceResources: {
|
||||
...app.optionalServiceResources,
|
||||
redis: {
|
||||
...app.optionalServiceResources?.redis,
|
||||
...dto.redisResources,
|
||||
storageGi:
|
||||
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.rabbitmqResources) {
|
||||
return {
|
||||
optionalServiceResources: {
|
||||
...app.optionalServiceResources,
|
||||
rabbitmq: {
|
||||
...app.optionalServiceResources?.rabbitmq,
|
||||
...dto.rabbitmqResources,
|
||||
storageGi:
|
||||
dto.rabbitmqResources.storageGi ??
|
||||
app.optionalServiceResources?.rabbitmq?.storageGi ??
|
||||
2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||
replicas: dto.replicas ?? app.replicas,
|
||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async applyUpgradeToKubernetes(
|
||||
app: Application,
|
||||
dto: UpgradeResourcesDto,
|
||||
previous: Application,
|
||||
): Promise<void> {
|
||||
const pt = app.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (pt === ProductType.MANAGED_DATABASE) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
},
|
||||
'database',
|
||||
);
|
||||
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
|
||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_REDIS) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_RABBITMQ) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dto.redisResources && app.enableRedis) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||
}
|
||||
|
||||
if (dto.rabbitmqResources && app.enableRabbitmq) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||
}
|
||||
|
||||
const touchesAppWorkload =
|
||||
dto.cpuRequest !== undefined ||
|
||||
dto.cpuLimit !== undefined ||
|
||||
dto.memoryRequest !== undefined ||
|
||||
dto.memoryLimit !== undefined ||
|
||||
dto.replicas !== undefined;
|
||||
|
||||
if (touchesAppWorkload) {
|
||||
await this.kubernetesService.updateResources(app, {
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
replicas: dto.replicas,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
||||
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
||||
}
|
||||
|
||||
if (
|
||||
dto.dbStorageSize &&
|
||||
dto.dbStorageSize !== previous.dbStorageSize &&
|
||||
previous.databaseType &&
|
||||
previous.databaseType !== DatabaseType.NONE
|
||||
) {
|
||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyOptionalServiceUpgrade(
|
||||
app: Application,
|
||||
dto: UpgradeResourcesDto,
|
||||
previous: Application,
|
||||
service: 'redis' | 'rabbitmq',
|
||||
): Promise<void> {
|
||||
const res = app.optionalServiceResources?.[service];
|
||||
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
|
||||
if (res) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: res.cpuRequest,
|
||||
cpuLimit: res.cpuLimit,
|
||||
memoryRequest: res.memoryRequest,
|
||||
memoryLimit: res.memoryLimit,
|
||||
},
|
||||
service,
|
||||
);
|
||||
}
|
||||
const prevGi =
|
||||
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
|
||||
const nextGi = dtoRes?.storageGi;
|
||||
if (nextGi != null && nextGi > prevGi) {
|
||||
const resize =
|
||||
service === 'redis'
|
||||
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
|
||||
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAppWithAccess(user: any, applicationId: string) {
|
||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||
|
||||
if (isAdminOrSales) {
|
||||
return this.applicationsService.findOne(applicationId);
|
||||
}
|
||||
|
||||
return this.applicationsService.findOne(applicationId, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BillingService } from './billing.service';
|
||||
import { assertStubGatewayAllowed } from './payment-gateway.util';
|
||||
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { BillingCycle, InvoiceReason } from '../common/enums';
|
||||
|
||||
@ApiTags('Billing')
|
||||
@ApiBearerAuth()
|
||||
@Controller('billing')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class BillingWalletController {
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
@Inject(forwardRef(() => AppLifecycleService))
|
||||
private readonly lifecycleService: AppLifecycleService,
|
||||
@Inject(forwardRef(() => ApplicationsService))
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
) {}
|
||||
|
||||
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||
|
||||
@Get('wallet')
|
||||
@ApiOperation({ summary: 'Get my wallet balance' })
|
||||
async getBalance(@Request() req: any) {
|
||||
return this.billingService.getBalance(req.user.id);
|
||||
}
|
||||
|
||||
@Post('wallet/charge')
|
||||
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
|
||||
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
||||
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
@ApiOperation({ summary: 'Get my wallet transactions' })
|
||||
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
|
||||
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
||||
}
|
||||
|
||||
@Get('resource-credits')
|
||||
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
|
||||
async getResourceCredits(@Request() req: any) {
|
||||
const credits = await this.billingService.getActiveCredits(req.user.id);
|
||||
return credits.map((c) => this.billingService.formatCreditForApi(c));
|
||||
}
|
||||
|
||||
@Post('wallet/pay/:applicationId')
|
||||
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
||||
async payForApplication(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() body: PayApplicationDto,
|
||||
) {
|
||||
const cycle = body.cycle as BillingCycle;
|
||||
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
||||
}
|
||||
|
||||
const app = await this.applicationsService.findOne(applicationId, req.user.id);
|
||||
|
||||
const payment = await this.billingService.resolveAppPayment(
|
||||
req.user.id,
|
||||
app,
|
||||
cycle,
|
||||
);
|
||||
|
||||
const coupon = await this.billingService.resolveCoupon(
|
||||
req.user.id,
|
||||
body.couponCode,
|
||||
await this.billingService.getAppChargeBreakdown(app),
|
||||
cycle,
|
||||
payment.amountDue,
|
||||
);
|
||||
|
||||
let invoice = null;
|
||||
if (payment.amountDue > 0) {
|
||||
invoice = await this.billingService.createInvoice({
|
||||
userId: req.user.id,
|
||||
applicationId: app.id,
|
||||
reason: InvoiceReason.DEPLOY,
|
||||
lines: [
|
||||
{
|
||||
label: `Application payment: ${app.name}`,
|
||||
description: `Billing cycle: ${cycle}`,
|
||||
amount: payment.amountDue,
|
||||
metadata: {
|
||||
cycle,
|
||||
waivedAmount: payment.waivedAmount,
|
||||
creditApplied: payment.creditId || null,
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
action: 'activate',
|
||||
cycle,
|
||||
},
|
||||
discount: coupon ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
let tx = null;
|
||||
if (invoice) {
|
||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||
tx = paid.transaction;
|
||||
invoice = paid.invoice;
|
||||
}
|
||||
|
||||
const activated = await this.lifecycleService.activateApp(
|
||||
applicationId,
|
||||
cycle,
|
||||
);
|
||||
|
||||
return {
|
||||
transaction: tx,
|
||||
invoice,
|
||||
creditApplied: payment.creditId || null,
|
||||
waivedAmount: payment.waivedAmount,
|
||||
discountAmount: coupon?.amount ?? 0,
|
||||
discountCode: coupon?.code ?? null,
|
||||
paidAmount: invoice ? Number(invoice.total) : 0,
|
||||
application: {
|
||||
id: activated.id,
|
||||
name: activated.name,
|
||||
lifecycleStatus: activated.lifecycleStatus,
|
||||
planExpiresAt: activated.planExpiresAt,
|
||||
},
|
||||
message: payment.waivedAmount > 0
|
||||
? payment.amountDue > 0
|
||||
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
|
||||
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
|
||||
: payment.amountDue > 0
|
||||
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
|
||||
: `Application "${activated.name}" activated`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Payment Gateway ─────────────────────────────────────────────
|
||||
|
||||
@Post('gateway/initiate')
|
||||
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
|
||||
async initiateGateway(
|
||||
@Request() req: any,
|
||||
@Body() body: { amount: number; description?: string; callbackUrl: string },
|
||||
) {
|
||||
assertStubGatewayAllowed();
|
||||
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
return {
|
||||
success: true,
|
||||
trackingCode,
|
||||
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
|
||||
message: 'Redirect user to gatewayUrl to complete payment',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('gateway/verify')
|
||||
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
|
||||
async verifyGateway(
|
||||
@Request() req: any,
|
||||
@Body() body: { trackingCode: string; amount: number },
|
||||
) {
|
||||
assertStubGatewayAllowed();
|
||||
await this.billingService.chargeWallet(
|
||||
req.user.id,
|
||||
body.amount,
|
||||
`Payment gateway: ${body.trackingCode}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Payment verified and wallet charged',
|
||||
trackingCode: body.trackingCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,35 +3,27 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingOpsService } from './billing-ops.service';
|
||||
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import {
|
||||
ChargeWalletDto,
|
||||
CalculateCostDto,
|
||||
CalculateDeployCostDto,
|
||||
SetOptionalServicesPricingDto,
|
||||
RenewApplicationDto,
|
||||
UpgradeResourcesDto,
|
||||
CalculateUpgradeCostDto,
|
||||
InitiateInvoicePaymentDto,
|
||||
VerifyInvoiceGatewayDto,
|
||||
UpdateInvoiceStatusDto,
|
||||
PayApplicationDto,
|
||||
} from './dto/billing.dto';
|
||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
@@ -41,12 +33,7 @@ import {
|
||||
BillingCycle,
|
||||
AppLifecycleStatus,
|
||||
InvoiceReason,
|
||||
InvoiceStatus,
|
||||
PaymentMethod,
|
||||
ProductType,
|
||||
DatabaseType,
|
||||
} from '../common/enums';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
|
||||
@ApiTags('Billing')
|
||||
@ApiBearerAuth()
|
||||
@@ -55,12 +42,11 @@ import { Application } from '../applications/entities/application.entity';
|
||||
export class BillingController {
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingOpsService: BillingOpsService,
|
||||
@Inject(forwardRef(() => AppLifecycleService))
|
||||
private readonly lifecycleService: AppLifecycleService,
|
||||
@Inject(forwardRef(() => ApplicationsService))
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
@Inject(forwardRef(() => KubernetesService))
|
||||
private readonly kubernetesService: KubernetesService,
|
||||
) {}
|
||||
|
||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||
@@ -152,274 +138,6 @@ export class BillingController {
|
||||
return this.billingService.setOptionalServicesPricing(dto);
|
||||
}
|
||||
|
||||
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||
|
||||
@Get('wallet')
|
||||
@ApiOperation({ summary: 'Get my wallet balance' })
|
||||
async getBalance(@Request() req: any) {
|
||||
return this.billingService.getBalance(req.user.id);
|
||||
}
|
||||
|
||||
@Post('wallet/charge')
|
||||
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
|
||||
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
||||
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
@ApiOperation({ summary: 'Get my wallet transactions' })
|
||||
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
|
||||
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
||||
}
|
||||
|
||||
@Get('resource-credits')
|
||||
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
|
||||
async getResourceCredits(@Request() req: any) {
|
||||
const credits = await this.billingService.getActiveCredits(req.user.id);
|
||||
return credits.map((c) => this.billingService.formatCreditForApi(c));
|
||||
}
|
||||
|
||||
// ─── Invoices ─────────────────────────────────────────────────────
|
||||
|
||||
@Get('invoices')
|
||||
@ApiOperation({ summary: 'List my invoices' })
|
||||
async listMyInvoices(
|
||||
@Request() req: any,
|
||||
@Query('status') status?: InvoiceStatus,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.billingService.listInvoices(req.user, {
|
||||
status,
|
||||
applicationId,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('invoices/:id')
|
||||
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
|
||||
async getInvoice(@Request() req: any, @Param('id') id: string) {
|
||||
return this.billingService.getInvoiceForUser(id, req.user);
|
||||
}
|
||||
|
||||
@Post('invoices/:id/pay/mixed')
|
||||
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
|
||||
async initiateInvoiceMixed(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: InitiateInvoicePaymentDto,
|
||||
) {
|
||||
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
|
||||
const effect = await this.completePaidInvoiceEffect(result.invoice);
|
||||
return { ...result, effect };
|
||||
}
|
||||
|
||||
@Post('invoices/:id/gateway/verify')
|
||||
@ApiOperation({ summary: 'Verify invoice gateway payment' })
|
||||
async verifyInvoiceGateway(
|
||||
@Request() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: VerifyInvoiceGatewayDto,
|
||||
) {
|
||||
const result = await this.billingService.verifyInvoiceGatewayPayment(
|
||||
id,
|
||||
req.user,
|
||||
dto.trackingCode,
|
||||
dto.amount,
|
||||
);
|
||||
const effect = await this.completePaidInvoiceEffect(result.invoice);
|
||||
return { ...result, effect };
|
||||
}
|
||||
|
||||
@Post('wallet/pay/:applicationId')
|
||||
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
||||
async payForApplication(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() body: PayApplicationDto,
|
||||
) {
|
||||
const cycle = body.cycle as BillingCycle;
|
||||
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
||||
}
|
||||
|
||||
const app = await this.applicationsService.findOne(applicationId, req.user.id);
|
||||
|
||||
const payment = await this.billingService.resolveAppPayment(
|
||||
req.user.id,
|
||||
app,
|
||||
cycle,
|
||||
);
|
||||
|
||||
const coupon = await this.billingService.resolveCoupon(
|
||||
req.user.id,
|
||||
body.couponCode,
|
||||
await this.billingService.getAppChargeBreakdown(app),
|
||||
cycle,
|
||||
payment.amountDue,
|
||||
);
|
||||
|
||||
let invoice = null;
|
||||
if (payment.amountDue > 0) {
|
||||
invoice = await this.billingService.createInvoice({
|
||||
userId: req.user.id,
|
||||
applicationId: app.id,
|
||||
reason: InvoiceReason.DEPLOY,
|
||||
lines: [
|
||||
{
|
||||
label: `Application payment: ${app.name}`,
|
||||
description: `Billing cycle: ${cycle}`,
|
||||
amount: payment.amountDue,
|
||||
metadata: {
|
||||
cycle,
|
||||
waivedAmount: payment.waivedAmount,
|
||||
creditApplied: payment.creditId || null,
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
action: 'activate',
|
||||
cycle,
|
||||
},
|
||||
discount: coupon ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
let tx = null;
|
||||
if (invoice) {
|
||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||
tx = paid.transaction;
|
||||
invoice = paid.invoice;
|
||||
}
|
||||
|
||||
const activated = await this.lifecycleService.activateApp(
|
||||
applicationId,
|
||||
cycle,
|
||||
);
|
||||
|
||||
return {
|
||||
transaction: tx,
|
||||
invoice,
|
||||
creditApplied: payment.creditId || null,
|
||||
waivedAmount: payment.waivedAmount,
|
||||
discountAmount: coupon?.amount ?? 0,
|
||||
discountCode: coupon?.code ?? null,
|
||||
paidAmount: invoice ? Number(invoice.total) : 0,
|
||||
application: {
|
||||
id: activated.id,
|
||||
name: activated.name,
|
||||
lifecycleStatus: activated.lifecycleStatus,
|
||||
planExpiresAt: activated.planExpiresAt,
|
||||
},
|
||||
message: payment.waivedAmount > 0
|
||||
? payment.amountDue > 0
|
||||
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
|
||||
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
|
||||
: payment.amountDue > 0
|
||||
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
|
||||
: `Application "${activated.name}" activated`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Payment Gateway ─────────────────────────────────────────────
|
||||
|
||||
@Post('gateway/initiate')
|
||||
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
|
||||
async initiateGateway(
|
||||
@Request() req: any,
|
||||
@Body() body: { amount: number; description?: string; callbackUrl: string },
|
||||
) {
|
||||
// In production, integrate with Zarinpal/IDPay/etc.
|
||||
// For now, simulate a gateway redirect URL.
|
||||
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
return {
|
||||
success: true,
|
||||
trackingCode,
|
||||
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
|
||||
message: 'Redirect user to gatewayUrl to complete payment',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('gateway/verify')
|
||||
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
|
||||
async verifyGateway(
|
||||
@Request() req: any,
|
||||
@Body() body: { trackingCode: string; amount: number },
|
||||
) {
|
||||
// In production, verify with the gateway provider.
|
||||
// For now, auto-approve and charge the wallet.
|
||||
await this.billingService.chargeWallet(
|
||||
req.user.id,
|
||||
body.amount,
|
||||
`Payment gateway: ${body.trackingCode}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Payment verified and wallet charged',
|
||||
trackingCode: body.trackingCode,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Invoice Admin ────────────────────────────────────────────────
|
||||
|
||||
@Get('admin/invoices')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all invoices (Admin)' })
|
||||
async listAdminInvoices(
|
||||
@Request() req: any,
|
||||
@Query('status') status?: InvoiceStatus,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('paymentMethod') paymentMethod?: PaymentMethod,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.billingService.listInvoices(req.user, {
|
||||
status,
|
||||
userId,
|
||||
applicationId,
|
||||
paymentMethod,
|
||||
search,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('admin/invoices/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get invoice details (Admin)' })
|
||||
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
|
||||
return this.billingService.getInvoiceForUser(id, req.user);
|
||||
}
|
||||
|
||||
@Patch('admin/invoices/:id/status')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
|
||||
async updateAdminInvoiceStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateInvoiceStatusDto,
|
||||
) {
|
||||
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
|
||||
}
|
||||
|
||||
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||
|
||||
@Get('admin/wallets')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all wallets (Admin)' })
|
||||
async getAllWallets() {
|
||||
return this.billingService.getAllWallets();
|
||||
}
|
||||
|
||||
@Post('admin/wallets/:userId/charge')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
|
||||
async adminChargeWallet(
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: ChargeWalletDto,
|
||||
) {
|
||||
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
|
||||
}
|
||||
|
||||
// ─── Application Renewal ──────────────────────────────────────────
|
||||
|
||||
@Get('applications/:applicationId/renewal-cost')
|
||||
@@ -428,8 +146,7 @@ export class BillingController {
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
) {
|
||||
// User can only view their own app, admin/sales can view any
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
return {
|
||||
applicationId: app.id,
|
||||
@@ -448,7 +165,7 @@ export class BillingController {
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: RenewApplicationDto,
|
||||
) {
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
@@ -490,10 +207,8 @@ export class BillingController {
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: RenewApplicationDto,
|
||||
) {
|
||||
// User can only renew their own app, admin/sales can renew any
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||
|
||||
// Calculate cost for the selected cycle
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
@@ -533,7 +248,6 @@ export class BillingController {
|
||||
|
||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||
|
||||
// Activate the application
|
||||
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
|
||||
|
||||
return {
|
||||
@@ -567,7 +281,6 @@ export class BillingController {
|
||||
}
|
||||
|
||||
if (body.bypassPayment) {
|
||||
// Direct activation without payment (for special cases, support, etc.)
|
||||
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
|
||||
return {
|
||||
success: true,
|
||||
@@ -583,7 +296,6 @@ export class BillingController {
|
||||
};
|
||||
}
|
||||
|
||||
// Normal renewal - deduct from app owner's wallet
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
@@ -640,7 +352,7 @@ export class BillingController {
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: CalculateUpgradeCostDto,
|
||||
) {
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||
const result = await this.billingService.calculateUpgradeCost(app, dto);
|
||||
|
||||
return {
|
||||
@@ -675,7 +387,7 @@ export class BillingController {
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: UpgradeResourcesDto,
|
||||
) {
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
|
||||
throw new BadRequestException(
|
||||
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
|
||||
@@ -727,20 +439,17 @@ export class BillingController {
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: UpgradeResourcesDto,
|
||||
) {
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||
|
||||
// Application must be active to upgrade
|
||||
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
|
||||
throw new BadRequestException(
|
||||
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate upgrade cost
|
||||
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
|
||||
let paidInvoice = null;
|
||||
|
||||
// If upgrading (positive difference), require payment
|
||||
if (costResult.proratedAmount > 0) {
|
||||
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
|
||||
? app.userId
|
||||
@@ -784,11 +493,11 @@ export class BillingController {
|
||||
const updatedApp = await this.applicationsService.update(
|
||||
app.id,
|
||||
app.userId,
|
||||
this.buildUpgradeEntityPatch(app, dto),
|
||||
this.billingOpsService.buildUpgradeEntityPatch(app, dto),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.applyUpgradeToKubernetes(updatedApp, dto, app);
|
||||
await this.billingOpsService.applyUpgradeToKubernetes(updatedApp, dto, app);
|
||||
} catch (e: any) {
|
||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
@@ -813,238 +522,4 @@ export class BillingController {
|
||||
: 'Resources updated (downgrade or no cost change).',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helper Methods ───────────────────────────────────────────────
|
||||
|
||||
private async completePaidInvoiceEffect(invoice: any) {
|
||||
if (invoice.status !== InvoiceStatus.PAID) return null;
|
||||
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
|
||||
|
||||
const action = invoice.metadata?.action;
|
||||
if (!action || !invoice.applicationId) return null;
|
||||
|
||||
if (action === 'renew' || action === 'activate') {
|
||||
const cycle = invoice.metadata?.cycle as BillingCycle;
|
||||
if (!Object.values(BillingCycle).includes(cycle)) return null;
|
||||
|
||||
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
|
||||
const result = {
|
||||
action,
|
||||
application: {
|
||||
id: activated.id,
|
||||
name: activated.name,
|
||||
lifecycleStatus: activated.lifecycleStatus,
|
||||
planExpiresAt: activated.planExpiresAt,
|
||||
billingCycle: activated.billingCycle,
|
||||
},
|
||||
};
|
||||
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (action === 'upgrade') {
|
||||
const app = await this.applicationsService.findOne(invoice.applicationId);
|
||||
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
|
||||
const updatedApp = await this.applicationsService.update(
|
||||
app.id,
|
||||
app.userId,
|
||||
this.buildUpgradeEntityPatch(app, resources),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
|
||||
} catch (e: any) {
|
||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
const result = {
|
||||
action,
|
||||
application: {
|
||||
id: updatedApp.id,
|
||||
name: updatedApp.name,
|
||||
cpuRequest: updatedApp.cpuRequest,
|
||||
cpuLimit: updatedApp.cpuLimit,
|
||||
memoryRequest: updatedApp.memoryRequest,
|
||||
memoryLimit: updatedApp.memoryLimit,
|
||||
replicas: updatedApp.replicas,
|
||||
},
|
||||
};
|
||||
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
||||
const pt = app.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (dto.redisResources) {
|
||||
return {
|
||||
optionalServiceResources: {
|
||||
...app.optionalServiceResources,
|
||||
redis: {
|
||||
...app.optionalServiceResources?.redis,
|
||||
...dto.redisResources,
|
||||
storageGi:
|
||||
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.rabbitmqResources) {
|
||||
return {
|
||||
optionalServiceResources: {
|
||||
...app.optionalServiceResources,
|
||||
rabbitmq: {
|
||||
...app.optionalServiceResources?.rabbitmq,
|
||||
...dto.rabbitmqResources,
|
||||
storageGi:
|
||||
dto.rabbitmqResources.storageGi ??
|
||||
app.optionalServiceResources?.rabbitmq?.storageGi ??
|
||||
2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||
replicas: dto.replicas ?? app.replicas,
|
||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyUpgradeToKubernetes(
|
||||
app: Application,
|
||||
dto: UpgradeResourcesDto,
|
||||
previous: Application,
|
||||
): Promise<void> {
|
||||
const pt = app.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (pt === ProductType.MANAGED_DATABASE) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
},
|
||||
'database',
|
||||
);
|
||||
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
|
||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_REDIS) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_RABBITMQ) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dto.redisResources && app.enableRedis) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||
}
|
||||
|
||||
if (dto.rabbitmqResources && app.enableRabbitmq) {
|
||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||
}
|
||||
|
||||
const touchesAppWorkload =
|
||||
dto.cpuRequest !== undefined ||
|
||||
dto.cpuLimit !== undefined ||
|
||||
dto.memoryRequest !== undefined ||
|
||||
dto.memoryLimit !== undefined ||
|
||||
dto.replicas !== undefined;
|
||||
|
||||
if (touchesAppWorkload) {
|
||||
await this.kubernetesService.updateResources(app, {
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
replicas: dto.replicas,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
||||
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
||||
}
|
||||
|
||||
if (
|
||||
dto.dbStorageSize &&
|
||||
dto.dbStorageSize !== previous.dbStorageSize &&
|
||||
previous.databaseType &&
|
||||
previous.databaseType !== DatabaseType.NONE
|
||||
) {
|
||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyOptionalServiceUpgrade(
|
||||
app: Application,
|
||||
dto: UpgradeResourcesDto,
|
||||
previous: Application,
|
||||
service: 'redis' | 'rabbitmq',
|
||||
): Promise<void> {
|
||||
const res = app.optionalServiceResources?.[service];
|
||||
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
|
||||
if (res) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: res.cpuRequest,
|
||||
cpuLimit: res.cpuLimit,
|
||||
memoryRequest: res.memoryRequest,
|
||||
memoryLimit: res.memoryLimit,
|
||||
},
|
||||
service,
|
||||
);
|
||||
}
|
||||
const prevGi =
|
||||
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
|
||||
const nextGi = dtoRes?.storageGi;
|
||||
if (nextGi != null && nextGi > prevGi) {
|
||||
const resize =
|
||||
service === 'redis'
|
||||
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
|
||||
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppWithAccess(user: any, applicationId: string) {
|
||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||
|
||||
if (isAdminOrSales) {
|
||||
return this.applicationsService.findOne(applicationId);
|
||||
}
|
||||
|
||||
// Regular user - must own the app
|
||||
return this.applicationsService.findOne(applicationId, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingOpsService } from './billing-ops.service';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { BillingWalletController } from './billing-wallet.controller';
|
||||
import { BillingInvoicesController } from './billing-invoices.controller';
|
||||
import { PublicPricingController } from './public-pricing.controller';
|
||||
import { DiscountController } from './discount.controller';
|
||||
import { DiscountService } from './discount.service';
|
||||
@@ -42,8 +45,14 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
forwardRef(() => ApplicationsModule),
|
||||
forwardRef(() => KubernetesModule),
|
||||
],
|
||||
controllers: [BillingController, PublicPricingController, DiscountController],
|
||||
providers: [BillingService, PricingCatalogService, DiscountService],
|
||||
exports: [BillingService, PricingCatalogService, DiscountService],
|
||||
controllers: [
|
||||
BillingController,
|
||||
BillingWalletController,
|
||||
BillingInvoicesController,
|
||||
PublicPricingController,
|
||||
DiscountController,
|
||||
],
|
||||
providers: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
|
||||
exports: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { assertStubGatewayAllowed } from './payment-gateway.util';
|
||||
|
||||
describe('assertStubGatewayAllowed', () => {
|
||||
const env = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
it('allows in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
delete process.env.PAYMENT_GATEWAY_STUB_ENABLED;
|
||||
expect(() => assertStubGatewayAllowed()).not.toThrow();
|
||||
});
|
||||
|
||||
it('blocks in production by default', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.PAYMENT_GATEWAY_STUB_ENABLED;
|
||||
expect(() => assertStubGatewayAllowed()).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows in production when explicitly enabled for staging', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.PAYMENT_GATEWAY_STUB_ENABLED = 'true';
|
||||
expect(() => assertStubGatewayAllowed()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Stub gateway endpoints auto-approve payments without a real provider.
|
||||
* Disabled in production unless PAYMENT_GATEWAY_STUB_ENABLED=true (staging only).
|
||||
*/
|
||||
export function assertStubGatewayAllowed(): void {
|
||||
if (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.PAYMENT_GATEWAY_STUB_ENABLED !== 'true'
|
||||
) {
|
||||
throw new ForbiddenException('Payment gateway is not configured');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import type { BuildProgress } from './build.service';
|
||||
|
||||
const KEY_PREFIX = 'build:progress:';
|
||||
const TTL_SECONDS = 3600;
|
||||
|
||||
@Injectable()
|
||||
export class BuildProgressStore implements OnModuleDestroy {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.redis = new Redis({
|
||||
host: this.configService.get<string>('redis.host'),
|
||||
port: this.configService.get<number>('redis.port'),
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
this.redis.connect().catch(() => {
|
||||
// Redis may be unavailable in local unit tests — in-memory fallback remains in BuildService.
|
||||
});
|
||||
}
|
||||
|
||||
async get(deploymentId: string): Promise<BuildProgress | null> {
|
||||
try {
|
||||
const raw = await this.redis.get(`${KEY_PREFIX}${deploymentId}`);
|
||||
return raw ? (JSON.parse(raw) as BuildProgress) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(deploymentId: string, progress: BuildProgress): Promise<void> {
|
||||
try {
|
||||
await this.redis.set(
|
||||
`${KEY_PREFIX}${deploymentId}`,
|
||||
JSON.stringify(progress),
|
||||
'EX',
|
||||
TTL_SECONDS,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — local map still holds progress for this replica.
|
||||
}
|
||||
}
|
||||
|
||||
async clear(deploymentId: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.del(`${KEY_PREFIX}${deploymentId}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.redis.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { BuildService } from './build.service';
|
||||
import { BuildProgressStore } from './build-progress.store';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { ClustersModule } from '../clusters/clusters.module';
|
||||
|
||||
@@ -8,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module';
|
||||
forwardRef(() => KubernetesModule),
|
||||
ClustersModule,
|
||||
],
|
||||
providers: [BuildService],
|
||||
providers: [BuildService, BuildProgressStore],
|
||||
exports: [BuildService],
|
||||
})
|
||||
export class BuildModule {}
|
||||
|
||||
@@ -1,469 +1,68 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BuildService } from './build.service';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
import { BuildProgressStore } from './build-progress.store';
|
||||
|
||||
/**
|
||||
* Tests for build service — Dockerfile generation for all runtimes
|
||||
*/
|
||||
describe('BuildService', () => {
|
||||
let service: BuildService;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Go Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Go Dockerfile generation', () => {
|
||||
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const goVersion = app.runtimeVersion || '1.22';
|
||||
const port = app.port || 8080;
|
||||
return `FROM golang:${goVersion}-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download || true
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BuildService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'build.namespace': 'cloudhost-builds',
|
||||
'build.serviceAccount': 'kaniko-builder',
|
||||
'registry.url': 'registry.local:5000',
|
||||
};
|
||||
return map[key];
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ provide: ClustersService, useValue: {} },
|
||||
{
|
||||
provide: BuildProgressStore,
|
||||
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
|
||||
},
|
||||
{ provide: RegistryService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
|
||||
COPY --from=builder /app/main .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
|
||||
CMD ["./main"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Go version', () => {
|
||||
const df = goDockerfile({ runtimeVersion: '1.21' });
|
||||
expect(df).toContain('FROM golang:1.21-alpine');
|
||||
service = module.get(BuildService);
|
||||
});
|
||||
|
||||
it('should default to Go 1.22', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('FROM golang:1.22-alpine');
|
||||
});
|
||||
describe('generateDockerfile', () => {
|
||||
it('generates Go Dockerfile with requested runtime version', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.GO,
|
||||
runtimeVersion: '1.22',
|
||||
port: 8080,
|
||||
} as Application;
|
||||
|
||||
it('should build static binary with CGO_ENABLED=0', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('CGO_ENABLED=0');
|
||||
});
|
||||
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||
|
||||
it('should use multi-stage build for smaller image', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
expect(df).toContain('FROM alpine:3.19');
|
||||
});
|
||||
expect(dockerfile).toContain('FROM golang:1.22-alpine');
|
||||
expect(dockerfile).toContain('EXPOSE 8080');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = goDockerfile({ port: 8080 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8080/health');
|
||||
});
|
||||
it('generates Node.js Dockerfile with default port', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.NODEJS,
|
||||
runtimeVersion: '20',
|
||||
} as Application;
|
||||
|
||||
it('should create data directory for persistent storage', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('mkdir -p /app/data');
|
||||
});
|
||||
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Python Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Python Dockerfile generation', () => {
|
||||
function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = pythonDockerfile({ runtimeVersion: '3.11' });
|
||||
expect(df).toContain('FROM python:3.11-slim');
|
||||
});
|
||||
|
||||
it('should default to Python 3.12', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('FROM python:3.12-slim');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
});
|
||||
|
||||
it('should install from requirements.txt', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('requirements.txt');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = pythonDockerfile({ port: 8000 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8000/health');
|
||||
});
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Django Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Django Dockerfile generation', () => {
|
||||
function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/data
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = djangoDockerfile({ runtimeVersion: '3.10' });
|
||||
expect(df).toContain('FROM python:3.10-slim');
|
||||
});
|
||||
|
||||
it('should set DJANGO_SETTINGS_MODULE', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('DJANGO_SETTINGS_MODULE');
|
||||
});
|
||||
|
||||
it('should create staticfiles and media directories', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('/app/staticfiles');
|
||||
expect(df).toContain('/app/media');
|
||||
});
|
||||
|
||||
it('should run migrations on startup', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('migrate');
|
||||
});
|
||||
|
||||
it('should use gunicorn for production', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('gunicorn');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// .NET Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('.NET Dockerfile generation', () => {
|
||||
function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const dotnetVersion = app.runtimeVersion || '8.0';
|
||||
const port = app.port || 5000;
|
||||
return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
|
||||
WORKDIR /src
|
||||
COPY *.csproj ./
|
||||
RUN dotnet restore || true
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/data
|
||||
USER appuser
|
||||
ENV ASPNETCORE_URLS=http://+:${port}
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["dotnet", "app.dll"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct .NET version', () => {
|
||||
const df = dotnetDockerfile({ runtimeVersion: '7.0' });
|
||||
expect(df).toContain('dotnet/sdk:7.0');
|
||||
expect(df).toContain('dotnet/aspnet:7.0');
|
||||
});
|
||||
|
||||
it('should default to .NET 8.0', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('dotnet/sdk:8.0');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('AS build');
|
||||
expect(df).toContain('dotnet/aspnet');
|
||||
});
|
||||
|
||||
it('should publish in Release mode', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('-c Release');
|
||||
});
|
||||
|
||||
it('should set ASPNETCORE_ENVIRONMENT to Production', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production');
|
||||
});
|
||||
|
||||
it('should configure ASPNETCORE_URLS for correct port', () => {
|
||||
const df = dotnetDockerfile({ port: 8080 });
|
||||
expect(df).toContain('ASPNETCORE_URLS=http://+:8080');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PHP Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('PHP Dockerfile generation', () => {
|
||||
function phpDockerfile(app: { phpVersion?: string; port?: number }): string {
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `FROM php:${phpVersion}-fpm-alpine
|
||||
RUN apk add --no-cache nginx supervisor curl
|
||||
RUN docker-php-ext-install pdo pdo_mysql opcache
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
RUN mkdir -p /var/www/html/uploads /var/www/html/data
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
EXPOSE ${port}
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct PHP version', () => {
|
||||
const df = phpDockerfile({ phpVersion: '8.2' });
|
||||
expect(df).toContain('FROM php:8.2-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should default to PHP 8.3', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('FROM php:8.3-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should use FPM with nginx via supervisord', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('supervisord');
|
||||
expect(df).toContain('nginx');
|
||||
});
|
||||
|
||||
it('should install common PHP extensions', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('pdo');
|
||||
expect(df).toContain('opcache');
|
||||
});
|
||||
|
||||
it('should create upload and data directories', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('/var/www/html/uploads');
|
||||
expect(df).toContain('/var/www/html/data');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for the WordPress build flow — specifically:
|
||||
* 1. Helper pod PVC race condition (must wait for termination)
|
||||
* 2. WordPress Dockerfile generation correctness
|
||||
* 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution
|
||||
*/
|
||||
|
||||
describe('WordPress Dockerfile generation', () => {
|
||||
// Reproduce the wordpressDockerfile logic from build.service.ts
|
||||
function wordpressDockerfile(app: {
|
||||
runtimeVersion?: string;
|
||||
phpVersion?: string;
|
||||
codePath?: string;
|
||||
port?: number;
|
||||
}): string {
|
||||
const wpVersion = app.runtimeVersion || '6.7';
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const hasUploadedCode = !!app.codePath;
|
||||
|
||||
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
|
||||
RUN docker-php-ext-install opcache
|
||||
RUN a2enmod rewrite
|
||||
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
|
||||
${hasUploadedCode ? `COPY . /tmp/user-content
|
||||
RUN mkdir -p /usr/src/wordpress-user
|
||||
ENTRYPOINT ["cloudhost-entrypoint.sh"]
|
||||
CMD []` : `CMD ["apache2-foreground"]`}
|
||||
EXPOSE 80
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use ENTRYPOINT (not CMD) when user uploaded code', () => {
|
||||
const df = wordpressDockerfile({ codePath: '/some/path/source.zip' });
|
||||
expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]');
|
||||
expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]');
|
||||
});
|
||||
|
||||
it('should use CMD apache2-foreground for fresh install (no code)', () => {
|
||||
const df = wordpressDockerfile({});
|
||||
expect(df).toContain('CMD ["apache2-foreground"]');
|
||||
expect(df).not.toContain('ENTRYPOINT');
|
||||
});
|
||||
|
||||
it('should use correct WordPress and PHP versions', () => {
|
||||
const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' });
|
||||
expect(df).toContain('FROM wordpress:6.4-php8.2-apache');
|
||||
});
|
||||
|
||||
it('should default to WP 6.7 and PHP 8.3', () => {
|
||||
const df = wordpressDockerfile({});
|
||||
expect(df).toContain('FROM wordpress:6.7-php8.3-apache');
|
||||
});
|
||||
|
||||
it('should COPY user content when codePath exists', () => {
|
||||
const df = wordpressDockerfile({ codePath: '/tmp/source.zip' });
|
||||
expect(df).toContain('COPY . /tmp/user-content');
|
||||
});
|
||||
|
||||
it('should NOT copy user content for fresh install', () => {
|
||||
const df = wordpressDockerfile({});
|
||||
expect(df).not.toContain('COPY . /tmp/user-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper pod PVC race condition', () => {
|
||||
it('should wait for pod deletion (not just fire-and-forget)', () => {
|
||||
// Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404
|
||||
const deletionSteps = [
|
||||
{ exists: true }, // pod still terminating
|
||||
{ exists: true }, // still terminating
|
||||
{ exists: false }, // gone (404)
|
||||
];
|
||||
|
||||
let pollCount = 0;
|
||||
let fullyTerminated = false;
|
||||
|
||||
for (const step of deletionSteps) {
|
||||
pollCount++;
|
||||
if (!step.exists) {
|
||||
fullyTerminated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(fullyTerminated).toBe(true);
|
||||
expect(pollCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should time out if pod never terminates', () => {
|
||||
const maxPolls = 30; // e.g. 60s / 2s interval
|
||||
let pollCount = 0;
|
||||
let timedOut = false;
|
||||
|
||||
while (pollCount < maxPolls) {
|
||||
pollCount++;
|
||||
// Pod always exists (simulating stuck termination)
|
||||
const exists = true;
|
||||
if (!exists) break;
|
||||
}
|
||||
|
||||
if (pollCount >= maxPolls) {
|
||||
timedOut = true;
|
||||
}
|
||||
|
||||
expect(timedOut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WordPress entrypoint script', () => {
|
||||
const entrypointScript = `#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Merge user wp-content into PVC
|
||||
if [ -d /usr/src/wordpress-user/wp-content ]; then
|
||||
mkdir -p /var/www/html/wp-content
|
||||
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
|
||||
chown -R www-data:www-data /var/www/html/wp-content
|
||||
fi
|
||||
|
||||
exec docker-entrypoint.sh apache2-foreground`;
|
||||
|
||||
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
|
||||
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should use exec to replace process', () => {
|
||||
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
|
||||
});
|
||||
|
||||
it('should merge wp-content on every start when staged content exists', () => {
|
||||
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
|
||||
expect(entrypointScript).not.toContain('.user-content-merged');
|
||||
});
|
||||
|
||||
it('should not copy user wp-config.php (credentials come from env vars)', () => {
|
||||
expect(entrypointScript).not.toContain('wp-config.php');
|
||||
});
|
||||
|
||||
it('should set proper ownership after merging wp-content', () => {
|
||||
expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WordPress zip structure handling', () => {
|
||||
// The unzip init container handles single-subfolder flattening
|
||||
it('should flatten single subfolder (public_html/) to root', () => {
|
||||
// Simulate: zip contains only public_html/
|
||||
const extractedItems = ['public_html'];
|
||||
const count = extractedItems.length;
|
||||
const firstItem = extractedItems[0];
|
||||
|
||||
let flattenedToRoot = false;
|
||||
if (count === 1 && firstItem === 'public_html') {
|
||||
// cp -a /tmp/extract/public_html/. /workspace-out/source/
|
||||
flattenedToRoot = true;
|
||||
}
|
||||
|
||||
expect(flattenedToRoot).toBe(true);
|
||||
});
|
||||
|
||||
it('should copy as-is when multiple items exist', () => {
|
||||
// Simulate: zip contains multiple items at root
|
||||
const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php'];
|
||||
const count = extractedItems.length;
|
||||
|
||||
let copiedAsIs = false;
|
||||
if (count !== 1) {
|
||||
copiedAsIs = true;
|
||||
}
|
||||
|
||||
expect(copiedAsIs).toBe(true);
|
||||
expect(dockerfile).toContain('FROM node:20');
|
||||
expect(dockerfile).toContain('EXPOSE 3000');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
import { BuildProgressStore } from './build-progress.store';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -55,6 +56,7 @@ export class BuildService {
|
||||
private configService: ConfigService,
|
||||
private clustersService: ClustersService,
|
||||
private registryService: RegistryService,
|
||||
private progressStore: BuildProgressStore,
|
||||
) {}
|
||||
|
||||
private beginBuildSession(deploymentId: string): void {
|
||||
@@ -277,17 +279,23 @@ export class BuildService {
|
||||
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
|
||||
}
|
||||
|
||||
getProgress(deploymentId: string): BuildProgress | null {
|
||||
return this.progressMap.get(deploymentId) ?? null;
|
||||
async getProgress(deploymentId: string): Promise<BuildProgress | null> {
|
||||
const local = this.progressMap.get(deploymentId);
|
||||
if (local) return local;
|
||||
const remote = await this.progressStore.get(deploymentId);
|
||||
if (remote) this.progressMap.set(deploymentId, remote);
|
||||
return remote;
|
||||
}
|
||||
|
||||
setProgress(deploymentId: string | undefined, progress: BuildProgress): void {
|
||||
if (!deploymentId) return;
|
||||
this.progressMap.set(deploymentId, progress);
|
||||
void this.progressStore.set(deploymentId, progress);
|
||||
}
|
||||
|
||||
clearProgress(deploymentId: string): void {
|
||||
this.progressMap.delete(deploymentId);
|
||||
void this.progressStore.clear(deploymentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,105 +1,105 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ClustersService } from './clusters.service';
|
||||
import { Cluster } from './entities/cluster.entity';
|
||||
import { ClusterHealth } from './entities/cluster-health.entity';
|
||||
import { ClusterPool } from './entities/cluster-pool.entity';
|
||||
import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
|
||||
import { ClusterStatus } from '../common/enums';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tests for ClustersService — getDefault and delete logic.
|
||||
*/
|
||||
describe('ClustersService', () => {
|
||||
let service: ClustersService;
|
||||
|
||||
describe('ClustersService getDefault logic', () => {
|
||||
// Simulate the fixed getDefault behavior
|
||||
function getDefault(clusters: { id: string; isDefault: boolean; status: string }[]): { id: string } | null {
|
||||
// Step 1: active + default
|
||||
let result = clusters.find(c => c.isDefault && c.status === ClusterStatus.ACTIVE);
|
||||
if (result) return { id: result.id };
|
||||
const clustersRepository = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
// Step 2: any active (fallback)
|
||||
result = clusters.find(c => c.status === ClusterStatus.ACTIVE);
|
||||
if (result) return { id: result.id };
|
||||
const healthRepository = { find: jest.fn(), save: jest.fn() };
|
||||
const poolRepository = { find: jest.fn(), findOne: jest.fn(), save: jest.fn() };
|
||||
const allocationLogsRepository = { save: jest.fn(), find: jest.fn() };
|
||||
const dataSource = { transaction: jest.fn() };
|
||||
const registryService = { ensureRegistryPullSecret: jest.fn() };
|
||||
|
||||
return null;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
it('should return active default cluster', () => {
|
||||
const clusters = [
|
||||
{ id: '1', isDefault: true, status: ClusterStatus.ACTIVE },
|
||||
{ id: '2', isDefault: false, status: ClusterStatus.ACTIVE },
|
||||
];
|
||||
expect(getDefault(clusters)?.id).toBe('1');
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClustersService,
|
||||
{ provide: getRepositoryToken(Cluster), useValue: clustersRepository },
|
||||
{ provide: getRepositoryToken(ClusterPool), useValue: poolRepository },
|
||||
{ provide: getRepositoryToken(ClusterHealth), useValue: healthRepository },
|
||||
{ provide: getRepositoryToken(ClusterAllocationLog), useValue: allocationLogsRepository },
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: RegistryService, useValue: registryService },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'CLUSTER_KUBECONFIG_KEY') return '';
|
||||
if (key === 'cluster.kubeconfigKey') return '';
|
||||
return undefined;
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ClustersService);
|
||||
});
|
||||
|
||||
it('should skip inactive default and return active cluster', () => {
|
||||
const clusters = [
|
||||
{ id: '1', isDefault: true, status: ClusterStatus.INACTIVE },
|
||||
{ id: '2', isDefault: false, status: ClusterStatus.ACTIVE },
|
||||
];
|
||||
expect(getDefault(clusters)?.id).toBe('2');
|
||||
});
|
||||
describe('getDefault', () => {
|
||||
it('returns active default cluster', async () => {
|
||||
const cluster = {
|
||||
id: 'c-1',
|
||||
name: 'primary',
|
||||
isDefault: true,
|
||||
status: ClusterStatus.ACTIVE,
|
||||
kubeconfig: 'apiVersion: v1',
|
||||
} as Cluster;
|
||||
|
||||
it('should return null when no active clusters exist', () => {
|
||||
const clusters = [
|
||||
{ id: '1', isDefault: true, status: ClusterStatus.INACTIVE },
|
||||
];
|
||||
expect(getDefault(clusters)).toBeNull();
|
||||
});
|
||||
clustersRepository.findOne.mockResolvedValueOnce(cluster);
|
||||
|
||||
it('should handle both clusters being default (picks active one)', () => {
|
||||
const clusters = [
|
||||
{ id: 'inactive', isDefault: true, status: ClusterStatus.INACTIVE },
|
||||
{ id: 'active', isDefault: true, status: ClusterStatus.ACTIVE },
|
||||
];
|
||||
expect(getDefault(clusters)?.id).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClustersService delete logic', () => {
|
||||
it('should reassign apps to replacement cluster on delete', () => {
|
||||
// Simulate: cluster A (being deleted) has 3 apps, cluster B is active
|
||||
const apps = [
|
||||
{ id: 'app1', clusterId: 'A' },
|
||||
{ id: 'app2', clusterId: 'A' },
|
||||
{ id: 'app3', clusterId: 'B' },
|
||||
];
|
||||
const deletedClusterId = 'A';
|
||||
const replacementId = 'B';
|
||||
|
||||
// Reassign
|
||||
for (const app of apps) {
|
||||
if (app.clusterId === deletedClusterId) {
|
||||
app.clusterId = replacementId;
|
||||
}
|
||||
}
|
||||
|
||||
expect(apps.filter(a => a.clusterId === 'A')).toHaveLength(0);
|
||||
expect(apps.filter(a => a.clusterId === 'B')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should promote another cluster to default when default is deleted', () => {
|
||||
const clusters = [
|
||||
{ id: 'A', isDefault: true, status: ClusterStatus.ACTIVE },
|
||||
{ id: 'B', isDefault: false, status: ClusterStatus.ACTIVE },
|
||||
];
|
||||
|
||||
// Delete A
|
||||
const deleted = clusters.splice(0, 1)[0];
|
||||
expect(deleted.isDefault).toBe(true);
|
||||
|
||||
// Promote
|
||||
const newDefault = clusters.find(c => c.status === ClusterStatus.ACTIVE);
|
||||
if (newDefault) newDefault.isDefault = true;
|
||||
|
||||
expect(clusters[0].isDefault).toBe(true);
|
||||
expect(clusters[0].id).toBe('B');
|
||||
});
|
||||
|
||||
it('should nullify clusterId when no replacement cluster exists', () => {
|
||||
const apps = [{ id: 'app1', clusterId: 'A' as string | null }];
|
||||
const hasReplacement = false;
|
||||
|
||||
if (!hasReplacement) {
|
||||
for (const app of apps) {
|
||||
app.clusterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
expect(apps[0].clusterId).toBeNull();
|
||||
const result = await service.getDefault();
|
||||
|
||||
expect(result.id).toBe('c-1');
|
||||
expect(clustersRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { isDefault: true, status: ClusterStatus.ACTIVE },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to any active cluster when no default is set', async () => {
|
||||
const fallback = {
|
||||
id: 'c-2',
|
||||
name: 'fallback',
|
||||
isDefault: false,
|
||||
status: ClusterStatus.ACTIVE,
|
||||
kubeconfig: 'apiVersion: v1',
|
||||
} as Cluster;
|
||||
|
||||
clustersRepository.findOne
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(fallback);
|
||||
|
||||
const result = await service.getDefault();
|
||||
|
||||
expect(result.id).toBe('c-2');
|
||||
expect(clustersRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when no active cluster exists', async () => {
|
||||
clustersRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getDefault()).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,11 @@ export default () => ({
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
},
|
||||
|
||||
cluster: {
|
||||
/** AES-256-GCM key for encrypting stored kubeconfigs. Required in production. */
|
||||
kubeconfigKey: process.env.CLUSTER_KUBECONFIG_KEY || '',
|
||||
},
|
||||
|
||||
// OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar').
|
||||
sms: {
|
||||
provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { validateProductionConfig } from './validate-production-config';
|
||||
|
||||
describe('validateProductionConfig', () => {
|
||||
const env = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
it('does nothing in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
delete process.env.JWT_SECRET;
|
||||
expect(() => validateProductionConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws in production when secrets are missing or default', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.JWT_SECRET = 'default-jwt-secret';
|
||||
process.env.JWT_REFRESH_SECRET = 'default-refresh-secret';
|
||||
process.env.DB_PASSWORD = 'cloudhost_secret';
|
||||
|
||||
expect(() => validateProductionConfig()).toThrow(/Production configuration validation failed/);
|
||||
expect(() => validateProductionConfig()).toThrow(/JWT_SECRET/);
|
||||
expect(() => validateProductionConfig()).toThrow(/CLUSTER_KUBECONFIG_KEY/);
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
expect(() => validateProductionConfig()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const DEFAULT_JWT_SECRET = 'default-jwt-secret';
|
||||
const DEFAULT_REFRESH_SECRET = 'default-refresh-secret';
|
||||
const DEFAULT_DB_PASSWORD = 'cloudhost_secret';
|
||||
|
||||
export function validateProductionConfig(): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || DEFAULT_JWT_SECRET;
|
||||
const refreshSecret = process.env.JWT_REFRESH_SECRET || DEFAULT_REFRESH_SECRET;
|
||||
const dbPassword = process.env.DB_PASSWORD || DEFAULT_DB_PASSWORD;
|
||||
|
||||
if (!process.env.JWT_SECRET || jwtSecret === DEFAULT_JWT_SECRET) {
|
||||
errors.push('JWT_SECRET must be set to a strong random value in production');
|
||||
}
|
||||
if (!process.env.JWT_REFRESH_SECRET || refreshSecret === DEFAULT_REFRESH_SECRET) {
|
||||
errors.push('JWT_REFRESH_SECRET must be set to a strong random value in production');
|
||||
}
|
||||
if (!process.env.DB_PASSWORD || dbPassword === DEFAULT_DB_PASSWORD) {
|
||||
errors.push('DB_PASSWORD must be changed from the default in production');
|
||||
}
|
||||
if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) {
|
||||
errors.push('CLUSTER_KUBECONFIG_KEY must be set in production to encrypt stored kubeconfigs');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Production configuration validation failed:\n${errors.map((e) => ` - ${e}`).join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { UserRole } from '../common/enums';
|
||||
|
||||
@ApiTags('Deployments')
|
||||
@ApiBearerAuth()
|
||||
@@ -18,6 +19,12 @@ import { RolesGuard } from '../common/guards/roles.guard';
|
||||
export class DeploymentsController {
|
||||
constructor(private readonly deploymentsService: DeploymentsService) {}
|
||||
|
||||
private ownershipUserId(req: { user: { id: string; role: string } }): string | undefined {
|
||||
const isStaff =
|
||||
req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
return isStaff ? undefined : req.user.id;
|
||||
}
|
||||
|
||||
@Post('applications/:appId/deploy')
|
||||
@ApiOperation({ summary: 'Trigger a new deployment' })
|
||||
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
|
||||
@@ -26,14 +33,14 @@ export class DeploymentsController {
|
||||
|
||||
@Get('applications/:appId')
|
||||
@ApiOperation({ summary: 'List deployments for an application' })
|
||||
async findByApplication(@Param('appId') appId: string) {
|
||||
return this.deploymentsService.findByApplication(appId);
|
||||
async findByApplication(@Param('appId') appId: string, @Request() req: any) {
|
||||
return this.deploymentsService.findByApplication(appId, this.ownershipUserId(req));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get deployment details' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.deploymentsService.findOne(id);
|
||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||
return this.deploymentsService.findOne(id, this.ownershipUserId(req));
|
||||
}
|
||||
|
||||
@Get('applications/:appId/logs')
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService } from '../build/build.service';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
|
||||
describe('DeploymentsService authorization', () => {
|
||||
let service: DeploymentsService;
|
||||
|
||||
const deploymentsRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const applicationsService = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DeploymentsService,
|
||||
{ provide: getRepositoryToken(Deployment), useValue: deploymentsRepository },
|
||||
{ provide: ApplicationsService, useValue: applicationsService },
|
||||
{ provide: KubernetesService, useValue: {} },
|
||||
{ provide: BuildService, useValue: {} },
|
||||
{ provide: ClustersService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DeploymentsService);
|
||||
});
|
||||
|
||||
describe('findByApplication', () => {
|
||||
it('verifies application ownership before listing deployments', async () => {
|
||||
const appId = 'app-1';
|
||||
const userId = 'user-1';
|
||||
const deployments = [{ id: 'd-1', applicationId: appId }] as Deployment[];
|
||||
|
||||
applicationsService.findOne.mockResolvedValue({ id: appId, userId });
|
||||
deploymentsRepository.find.mockResolvedValue(deployments);
|
||||
|
||||
const result = await service.findByApplication(appId, userId);
|
||||
|
||||
expect(applicationsService.findOne).toHaveBeenCalledWith(appId, userId);
|
||||
expect(result).toEqual(deployments);
|
||||
});
|
||||
|
||||
it('propagates NotFoundException when user does not own the app', async () => {
|
||||
applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found'));
|
||||
|
||||
await expect(service.findByApplication('app-1', 'other-user')).rejects.toThrow(NotFoundException);
|
||||
expect(deploymentsRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('verifies application ownership before returning deployment', async () => {
|
||||
const deployment = {
|
||||
id: 'd-1',
|
||||
applicationId: 'app-1',
|
||||
application: { id: 'app-1' },
|
||||
} as Deployment;
|
||||
|
||||
deploymentsRepository.findOne.mockResolvedValue(deployment);
|
||||
applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-1' });
|
||||
|
||||
const result = await service.findOne('d-1', 'user-1');
|
||||
|
||||
expect(applicationsService.findOne).toHaveBeenCalledWith('app-1', 'user-1');
|
||||
expect(result).toBe(deployment);
|
||||
});
|
||||
|
||||
it('throws when deployment does not exist', async () => {
|
||||
deploymentsRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('missing', 'user-1')).rejects.toThrow(NotFoundException);
|
||||
expect(applicationsService.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -463,14 +463,15 @@ export class DeploymentsService {
|
||||
}
|
||||
}
|
||||
|
||||
async findByApplication(applicationId: string): Promise<Deployment[]> {
|
||||
async findByApplication(applicationId: string, userId?: string): Promise<Deployment[]> {
|
||||
await this.applicationsService.findOne(applicationId, userId);
|
||||
return this.deploymentsRepository.find({
|
||||
where: { applicationId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Deployment> {
|
||||
async findOne(id: string, userId?: string): Promise<Deployment> {
|
||||
const deployment = await this.deploymentsRepository.findOne({
|
||||
where: { id },
|
||||
relations: { application: true },
|
||||
@@ -478,6 +479,7 @@ export class DeploymentsService {
|
||||
if (!deployment) {
|
||||
throw new NotFoundException('Deployment not found');
|
||||
}
|
||||
await this.applicationsService.findOne(deployment.applicationId, userId);
|
||||
return deployment;
|
||||
}
|
||||
|
||||
@@ -537,7 +539,7 @@ export class DeploymentsService {
|
||||
|
||||
if (!latest) return null;
|
||||
|
||||
const progress = this.buildService.getProgress(latest.id);
|
||||
const progress = await this.buildService.getProgress(latest.id);
|
||||
if (progress) return progress;
|
||||
|
||||
// No in-memory progress — infer from deployment status
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller()
|
||||
@SkipThrottle()
|
||||
export class HealthController {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: 'Liveness probe' })
|
||||
health() {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
|
||||
async ready() {
|
||||
await this.dataSource.query('SELECT 1');
|
||||
return { status: 'ready', timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -26,10 +26,10 @@ describe('HelmService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('chartPath', () => {
|
||||
describe('resolveChartPath', () => {
|
||||
it('should resolve to helm/cloudhost-app relative to project root', () => {
|
||||
const expectedSuffix = path.join('helm', 'cloudhost-app');
|
||||
expect((service as any).chartPath).toContain(expectedSuffix);
|
||||
expect((service as any).resolveChartPath('cloudhost-app')).toContain(expectedSuffix);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ jest.mock('@kubernetes/client-node', () => ({
|
||||
|
||||
import { RegistryService } from './registry.service';
|
||||
import { KubernetesService } from './kubernetes.service';
|
||||
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||
|
||||
/**
|
||||
* Regression tests for the @kubernetes/client-node 1.x migration.
|
||||
@@ -80,19 +81,25 @@ describe('KubernetesService — k8s v1 client shape', () => {
|
||||
let service: KubernetesService;
|
||||
|
||||
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
|
||||
const k8sClientService = {
|
||||
getK8sClient: jest.fn().mockResolvedValue({
|
||||
coreApi: clients.coreApi,
|
||||
appsApi: clients.appsApi,
|
||||
networkingApi: clients.networkingApi,
|
||||
kc: clients.kc,
|
||||
}),
|
||||
getKubeconfig: jest.fn(),
|
||||
};
|
||||
const k8sLifecycleService = new K8sLifecycleService(k8sClientService as any);
|
||||
const svc = new KubernetesService(
|
||||
configStub,
|
||||
{} as any, // clustersService
|
||||
{} as any, // helmService
|
||||
{} as any, // registryService
|
||||
k8sClientService as any,
|
||||
k8sLifecycleService,
|
||||
{} as any, // deploymentsRepository
|
||||
);
|
||||
jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({
|
||||
coreApi: clients.coreApi,
|
||||
appsApi: clients.appsApi,
|
||||
networkingApi: clients.networkingApi,
|
||||
kc: clients.kc,
|
||||
});
|
||||
return svc;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||
|
||||
@Injectable()
|
||||
export class K8sClientService {
|
||||
constructor(private readonly clustersService: ClustersService) {}
|
||||
|
||||
async getK8sClient(clusterId?: string): Promise<{
|
||||
coreApi: k8s.CoreV1Api;
|
||||
appsApi: k8s.AppsV1Api;
|
||||
networkingApi: k8s.NetworkingV1Api;
|
||||
kc: k8s.KubeConfig;
|
||||
}> {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
registerKubeconfigNoProxy(cluster.kubeconfig);
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
|
||||
return {
|
||||
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||
kc,
|
||||
};
|
||||
}
|
||||
|
||||
async getKubeconfig(clusterId?: string): Promise<string> {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
return cluster.kubeconfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { K8sClientService } from './k8s-client.service';
|
||||
import { primaryWorkloadLabel, userNamespace } from './k8s-workload.util';
|
||||
|
||||
/** Runtime logs and lightweight workload operations extracted from KubernetesService. */
|
||||
@Injectable()
|
||||
export class K8sLifecycleService {
|
||||
constructor(private readonly k8sClient: K8sClientService) {}
|
||||
|
||||
async getPodLogs(app: Application): Promise<string> {
|
||||
const { coreApi } = await this.k8sClient.getK8sClient(app.clusterId);
|
||||
const namespace = userNamespace(app.userId);
|
||||
const podLabel = primaryWorkloadLabel(app);
|
||||
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `app=${podLabel}`,
|
||||
});
|
||||
|
||||
if (pods.items.length === 0) {
|
||||
return 'No pods found for this application.';
|
||||
}
|
||||
|
||||
const podName = pods.items[0].metadata?.name;
|
||||
if (!podName) return 'Pod name not found.';
|
||||
|
||||
return coreApi.readNamespacedPodLog({
|
||||
name: podName,
|
||||
namespace,
|
||||
tailLines: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { DatabaseType, isManagedProductType } from '../common/enums';
|
||||
|
||||
/** Kubernetes namespace for a user's applications. */
|
||||
export function userNamespace(userId: string): string {
|
||||
return `user-${userId.split('-')[0]}`;
|
||||
}
|
||||
|
||||
/** Primary pod label selector target for an application workload. */
|
||||
export function primaryWorkloadLabel(app: Application): string {
|
||||
if (isManagedProductType(app.productType)) {
|
||||
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
|
||||
return `${app.name}-db`;
|
||||
}
|
||||
if (app.enableRedis) return `${app.name}-redis`;
|
||||
if (app.enableRabbitmq) return `${app.name}-rabbitmq`;
|
||||
}
|
||||
return app.name;
|
||||
}
|
||||
|
||||
export function getApplicationWorkloadDeployments(
|
||||
app: Application,
|
||||
): { name: string; runningReplicas: number }[] {
|
||||
const managed = isManagedProductType(app.productType);
|
||||
const workloads: { name: string; runningReplicas: number }[] = [];
|
||||
|
||||
if (!managed) {
|
||||
workloads.push({ name: app.name, runningReplicas: app.replicas || 1 });
|
||||
}
|
||||
|
||||
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
|
||||
workloads.push({ name: `${app.name}-db`, runningReplicas: 1 });
|
||||
}
|
||||
if (app.enableRedis) {
|
||||
workloads.push({ name: `${app.name}-redis`, runningReplicas: 1 });
|
||||
}
|
||||
if (app.enableRabbitmq) {
|
||||
workloads.push({ name: `${app.name}-rabbitmq`, runningReplicas: 1 });
|
||||
}
|
||||
|
||||
return workloads;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KubernetesService } from './kubernetes.service';
|
||||
import { HelmService } from './helm.service';
|
||||
import { RegistryService } from './registry.service';
|
||||
import { K8sClientService } from './k8s-client.service';
|
||||
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||
import { ElasticsearchService } from './elasticsearch.service';
|
||||
import { ElasticsearchController } from './elasticsearch.controller';
|
||||
import { LogsController } from './logs.controller';
|
||||
@@ -13,7 +15,7 @@ import { Deployment } from '../deployments/entities/deployment.entity';
|
||||
@Module({
|
||||
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])],
|
||||
controllers: [ElasticsearchController, LogsController],
|
||||
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
||||
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
||||
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
|
||||
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
|
||||
})
|
||||
export class KubernetesModule {}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { ensureAppUrlEnv } from '../applications/app-url.util';
|
||||
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums';
|
||||
import { HelmService } from './helm.service';
|
||||
import { RegistryService } from './registry.service';
|
||||
import { K8sClientService } from './k8s-client.service';
|
||||
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -74,6 +76,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
private clustersService: ClustersService,
|
||||
private helmService: HelmService,
|
||||
private registryService: RegistryService,
|
||||
private k8sClientService: K8sClientService,
|
||||
private k8sLifecycleService: K8sLifecycleService,
|
||||
@InjectRepository(Deployment)
|
||||
private deploymentsRepository: Repository<Deployment>,
|
||||
) {}
|
||||
@@ -99,34 +103,6 @@ export class KubernetesService implements OnModuleInit {
|
||||
// Helm chart is used for deployments — no local template loading needed
|
||||
}
|
||||
|
||||
private async getK8sClient(clusterId?: string): Promise<{
|
||||
coreApi: k8s.CoreV1Api;
|
||||
appsApi: k8s.AppsV1Api;
|
||||
networkingApi: k8s.NetworkingV1Api;
|
||||
kc: k8s.KubeConfig;
|
||||
}> {
|
||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
registerKubeconfigNoProxy(cluster.kubeconfig);
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
|
||||
return {
|
||||
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||
kc,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw kubeconfig string for a cluster.
|
||||
*/
|
||||
private async getKubeconfig(clusterId?: string): Promise<string> {
|
||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
||||
return cluster.kubeconfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Helm values object from an Application entity and image URI.
|
||||
*/
|
||||
@@ -412,7 +388,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
|
||||
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const managed = isManagedProductType(app.productType);
|
||||
const workloads = [
|
||||
@@ -463,14 +439,14 @@ export class KubernetesService implements OnModuleInit {
|
||||
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
|
||||
|
||||
try {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
const imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : '';
|
||||
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
||||
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
|
||||
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
||||
} catch (helmError: any) {
|
||||
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
|
||||
const { networkingApi } = await this.getK8sClient(app.clusterId);
|
||||
const { networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const ctx: ManifestContext = {
|
||||
appName: app.name,
|
||||
namespace,
|
||||
@@ -513,9 +489,9 @@ export class KubernetesService implements OnModuleInit {
|
||||
// ── Helm-based deployment ─────────────────────────────────────────
|
||||
|
||||
private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise<Record<string, any>> {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
await this.ensurePlatformStorageClass(kubeconfig);
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
||||
const namespace = values.app.namespace as string;
|
||||
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
||||
@@ -531,7 +507,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
await this.ensurePlatformStorageClass(kubeconfig);
|
||||
const values = this.buildManagedHelmValues(app);
|
||||
const namespace = values.app.namespace;
|
||||
@@ -549,8 +525,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
// ── Direct K8s API deployment (fallback) ──────────────────────────
|
||||
|
||||
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
|
||||
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
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 context: ManifestContext = {
|
||||
@@ -619,8 +595,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
if (isManagedProductType(app.productType)) {
|
||||
return this.deployManagedViaK8sApi(app);
|
||||
}
|
||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
await this.ensurePlatformStorageClass(kubeconfig);
|
||||
const domain = this.configService.get('platform.domain');
|
||||
|
||||
@@ -2247,33 +2223,11 @@ export class KubernetesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async getPodLogs(app: Application): Promise<string> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const podLabel = this.primaryWorkloadLabel(app);
|
||||
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `app=${podLabel}`,
|
||||
});
|
||||
|
||||
if (pods.items.length === 0) {
|
||||
return 'No pods found for this application.';
|
||||
}
|
||||
|
||||
const podName = pods.items[0].metadata?.name;
|
||||
if (!podName) return 'Pod name not found.';
|
||||
|
||||
const logResponse = await coreApi.readNamespacedPodLog({
|
||||
name: podName,
|
||||
namespace,
|
||||
tailLines: 200,
|
||||
});
|
||||
|
||||
return logResponse;
|
||||
return this.k8sLifecycleService.getPodLogs(app);
|
||||
}
|
||||
|
||||
async scaleDeployment(app: Application, replicas: number): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
|
||||
@@ -2313,7 +2267,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const snapshot: Record<string, number> = {};
|
||||
|
||||
@@ -2342,7 +2296,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Returns the replica snapshot captured before scaling.
|
||||
*/
|
||||
async suspendApplication(app: Application): Promise<Record<string, number>> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
|
||||
@@ -2368,7 +2322,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Resume a suspended application using saved replica counts when available.
|
||||
*/
|
||||
async resumeApplication(app: Application): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
|
||||
@@ -2400,7 +2354,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async restartDeployment(app: Application): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
|
||||
|
||||
@@ -2595,7 +2549,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Includes application workload, database, and optional Redis / RabbitMQ when enabled.
|
||||
*/
|
||||
async getResourceUsage(app: Application): Promise<any> {
|
||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
const workloads: any[] = [];
|
||||
@@ -2685,7 +2639,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
},
|
||||
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
|
||||
): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
const target = this.workloadDeploymentTarget(app, workload);
|
||||
@@ -2807,7 +2761,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
throw new BadRequestException('Application is not assigned to a cluster');
|
||||
}
|
||||
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = this.getUserNamespace(app.userId);
|
||||
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
|
||||
const shortId = grantId.split('-')[0];
|
||||
@@ -2874,7 +2828,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
const manifestPath = path.join(tmpDir, 'service.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(kubeconfigPath, await this.getKubeconfig(clusterId), {
|
||||
fs.writeFileSync(kubeconfigPath, await this.k8sClientService.getKubeconfig(clusterId), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 });
|
||||
@@ -2895,7 +2849,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise<void> {
|
||||
const { coreApi } = await this.getK8sClient(clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(clusterId);
|
||||
try {
|
||||
await coreApi.deleteNamespacedService({
|
||||
name: k8sServiceName,
|
||||
@@ -2912,7 +2866,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
|
||||
if (!app.clusterId) return;
|
||||
const namespace = this.getUserNamespace(app.userId);
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
|
||||
try {
|
||||
const services = await coreApi.listNamespacedService({
|
||||
@@ -2942,7 +2896,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
};
|
||||
case ServiceAccessTarget.REDIS: {
|
||||
if (!app.clusterId) return {};
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const secret = await coreApi.readNamespacedSecret({
|
||||
name: `${app.name}-redis-secret`,
|
||||
namespace,
|
||||
@@ -2953,7 +2907,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
|
||||
if (!app.clusterId) return {};
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const secret = await coreApi.readNamespacedSecret({
|
||||
name: `${app.name}-rabbitmq-secret`,
|
||||
namespace,
|
||||
@@ -2980,7 +2934,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
host: string;
|
||||
ingressUrl?: string;
|
||||
}> {
|
||||
const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, networkingApi, 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);
|
||||
@@ -3044,13 +2998,13 @@ export class KubernetesService implements OnModuleInit {
|
||||
|
||||
async deleteApplication(app: Application): Promise<void> {
|
||||
const namespace = this.getUserNamespace(app.userId);
|
||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
|
||||
await this.deleteTemporaryAccessServicesForApp(app);
|
||||
|
||||
// Step 1: Try Helm uninstall (handles most resources)
|
||||
try {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
await this.helmService.uninstall(app.name, namespace, kubeconfig);
|
||||
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
|
||||
} catch (error: any) {
|
||||
@@ -3171,8 +3125,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const namespace = this.getUserNamespace(app.userId);
|
||||
const source = await this.getK8sClient(app.clusterId);
|
||||
const target = await this.getK8sClient(targetClusterId);
|
||||
const source = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const target = await this.k8sClientService.getK8sClient(targetClusterId);
|
||||
|
||||
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
|
||||
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace });
|
||||
@@ -3250,8 +3204,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
|
||||
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
fs.writeFileSync(sourceKubeconfig, await this.getKubeconfig(sourceClusterId), { mode: 0o600 });
|
||||
fs.writeFileSync(targetKubeconfig, await this.getKubeconfig(targetClusterId), { mode: 0o600 });
|
||||
fs.writeFileSync(sourceKubeconfig, await this.k8sClientService.getKubeconfig(sourceClusterId), { mode: 0o600 });
|
||||
fs.writeFileSync(targetKubeconfig, await this.k8sClientService.getKubeconfig(targetClusterId), { mode: 0o600 });
|
||||
|
||||
try {
|
||||
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
|
||||
@@ -3407,7 +3361,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Polls pod status with label selector `app=<appName>-db`.
|
||||
*/
|
||||
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const dbLabel = `${app.name}-db`;
|
||||
const start = Date.now();
|
||||
@@ -3513,7 +3467,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* then runs a restore Job that mounts the PVC and imports the dump.
|
||||
*/
|
||||
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const dbName = `${app.name}-db`;
|
||||
@@ -3821,7 +3775,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Used when legacy PVCs were created without storageClassName.
|
||||
*/
|
||||
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
|
||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const oldPvcName = `${app.name}-db`;
|
||||
@@ -3997,7 +3951,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* K8s only supports PVC expansion, not shrinking.
|
||||
*/
|
||||
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pvcName = `${app.name}-db`;
|
||||
|
||||
@@ -4070,7 +4024,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
*/
|
||||
async getDatabasePvcSize(app: Application): Promise<string> {
|
||||
try {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pvcName = `${app.name}-db`;
|
||||
|
||||
@@ -4096,7 +4050,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
totalAllocatedGb: number;
|
||||
totalUsedGb: number;
|
||||
}> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
const result = {
|
||||
@@ -4271,7 +4225,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Returns usage in GB.
|
||||
*/
|
||||
private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise<number> {
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
@@ -4305,7 +4259,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
|
||||
*/
|
||||
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
try {
|
||||
@@ -4362,7 +4316,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Resize app storage PVC (all app types).
|
||||
*/
|
||||
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
// Try new unified name first, then legacy wp-content name
|
||||
@@ -4427,7 +4381,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
|
||||
*/
|
||||
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const dbName = `${app.name}-db`;
|
||||
@@ -4608,7 +4562,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Strategy: Create archive, then sleep to allow exec retrieval.
|
||||
*/
|
||||
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pvcName = `${app.name}-storage`;
|
||||
@@ -4760,7 +4714,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Restore wp-content from a tar.gz archive into the WordPress PVC.
|
||||
*/
|
||||
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
|
||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pvcName = `${app.name}-storage`;
|
||||
@@ -4902,7 +4856,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
const releaseName = app.name;
|
||||
|
||||
try {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig);
|
||||
|
||||
if (!helmRevisions || helmRevisions.length === 0) {
|
||||
@@ -4940,7 +4894,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
const releaseName = app.name;
|
||||
|
||||
try {
|
||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
|
||||
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import { validateProductionConfig } from './config/validate-production-config';
|
||||
|
||||
// Prevent Node.js from crashing on unhandled errors
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
@@ -15,6 +16,8 @@ process.on('uncaughtException', (error) => {
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
validateProductionConfig();
|
||||
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Prevent Jest from loading ESM-only @kubernetes/client-node in unit tests. */
|
||||
jest.mock('@kubernetes/client-node', () => ({
|
||||
KubeConfig: jest.fn(),
|
||||
CoreV1Api: jest.fn(),
|
||||
AppsV1Api: jest.fn(),
|
||||
BatchV1Api: jest.fn(),
|
||||
NetworkingV1Api: jest.fn(),
|
||||
CustomObjectsApi: jest.fn(),
|
||||
HttpError: class HttpError extends Error {},
|
||||
}));
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DeploymentsService } from '../src/deployments/deployments.service';
|
||||
import { Deployment } from '../src/deployments/entities/deployment.entity';
|
||||
import { ApplicationsService } from '../src/applications/applications.service';
|
||||
import { KubernetesService } from '../src/kubernetes/kubernetes.service';
|
||||
import { BuildService } from '../src/build/build.service';
|
||||
import { ClustersService } from '../src/clusters/clusters.service';
|
||||
|
||||
/**
|
||||
* Smoke test: deployment reads must enforce application ownership (IDOR fix).
|
||||
*/
|
||||
describe('Deployments authorization (e2e smoke)', () => {
|
||||
let service: DeploymentsService;
|
||||
|
||||
const deploymentsRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const applicationsService = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DeploymentsService,
|
||||
{ provide: getRepositoryToken(Deployment), useValue: deploymentsRepository },
|
||||
{ provide: ApplicationsService, useValue: applicationsService },
|
||||
{ provide: KubernetesService, useValue: {} },
|
||||
{ provide: BuildService, useValue: {} },
|
||||
{ provide: ClustersService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DeploymentsService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('rejects findOne when application ownership check fails', async () => {
|
||||
deploymentsRepository.findOne.mockResolvedValue({
|
||||
id: 'd-1',
|
||||
applicationId: 'app-other',
|
||||
});
|
||||
applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found'));
|
||||
|
||||
await expect(service.findOne('d-1', 'user-a')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('allows findOne when user owns the application', async () => {
|
||||
const deployment = { id: 'd-1', applicationId: 'app-1' };
|
||||
deploymentsRepository.findOne.mockResolvedValue(deployment);
|
||||
applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-a' });
|
||||
|
||||
await expect(service.findOne('d-1', 'user-a')).resolves.toBe(deployment);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFilesAfterEnv": ["<rootDir>/../src/test-setup.ts"]
|
||||
}
|
||||
+4
-2
@@ -55,10 +55,11 @@ services:
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: cloudhost
|
||||
DB_PASSWORD: cloudhost_secret
|
||||
DB_NAME: cloudhost
|
||||
DB_DATABASE: cloudhost
|
||||
|
||||
# JWT
|
||||
JWT_SECRET: change-this-to-a-long-random-string
|
||||
JWT_REFRESH_SECRET: change-this-to-a-long-random-refresh-string
|
||||
JWT_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
|
||||
@@ -73,9 +74,10 @@ services:
|
||||
|
||||
# Build
|
||||
BUILD_NAMESPACE: cloudhost-builds
|
||||
KANIKO_IMAGE: gcr.io/kaniko-project/executor:latest
|
||||
KANIKO_IMAGE: gcr.io/kaniko-project/executor:v1.23.2
|
||||
|
||||
# Platform
|
||||
FRONTEND_URL: http://localhost:3000
|
||||
PLATFORM_DOMAIN: apps.localhost
|
||||
volumes:
|
||||
- /tmp/cloudhost-uploads:/app/uploads
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: dirname(fileURLToPath(import.meta.url)),
|
||||
});
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{
|
||||
ignores: ['.next/**', 'node_modules/**'],
|
||||
},
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
];
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Generated
+1457
-1
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
@@ -36,8 +39,10 @@
|
||||
"@types/three": "^0.184.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import type { Application, Deployment, ResourceUsage } from '@/types';
|
||||
|
||||
/** Core data queries for the application detail page. */
|
||||
export function useAppQueries(appId: string, enabled = true) {
|
||||
const appQuery = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
enabled: enabled && !!appId,
|
||||
});
|
||||
|
||||
const deploymentsQuery = useQuery<Deployment[]>({
|
||||
queryKey: ['deployments', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
||||
enabled: enabled && !!appId,
|
||||
});
|
||||
|
||||
const usageQuery = useQuery<ResourceUsage>({
|
||||
queryKey: ['resource-usage', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/resources/usage`).then((r) => r.data),
|
||||
enabled: enabled && !!appId && appQuery.data?.lifecycleStatus === 'active',
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const walletQuery = useQuery<{ balance: number }>({
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
|
||||
return { appQuery, deploymentsQuery, usageQuery, walletQuery };
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
@@ -250,7 +251,7 @@ export default function AppDetailPage() {
|
||||
|
||||
// ─── Billing & Renewal ──────────────────────────────
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -271,7 +272,7 @@ export default function AppDetailPage() {
|
||||
onSuccess: (res) => {
|
||||
notify.success(res.data.message || 'Application renewed successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
setShowRenewalModal(false);
|
||||
setRenewCoupon('');
|
||||
},
|
||||
@@ -646,7 +647,7 @@ export default function AppDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
setPendingUpgradePayload(null);
|
||||
@@ -2134,22 +2135,22 @@ export default function AppDetailPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-1 font-medium">{ad.name}</th>
|
||||
<th className="pb-1 font-medium">{ad.status}</th>
|
||||
<th className="pb-1 font-medium">{ad.ready}</th>
|
||||
<th className="pb-1 font-medium">R</th>
|
||||
<tr className="text-start text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-1 font-medium text-start">{ad.name}</th>
|
||||
<th className="pb-1 font-medium text-start">{ad.status}</th>
|
||||
<th className="pb-1 font-medium text-start">{ad.ready}</th>
|
||||
<th className="pb-1 font-medium text-start">{ad.restarts}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{w.pods.map((pod) => (
|
||||
<tr key={pod.name} className="text-gray-700">
|
||||
<td className="py-1 font-mono truncate max-w-[140px]" title={pod.name}>{pod.name}</td>
|
||||
<td className="py-1">
|
||||
<td className="py-1 text-start font-mono truncate max-w-[140px]" dir="ltr" title={pod.name}>{pod.name}</td>
|
||||
<td className="py-1 text-start">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${pod.status === 'Running' ? 'bg-green-100 text-green-700' : pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>{pod.status}</span>
|
||||
</td>
|
||||
<td className="py-1">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
|
||||
<td className="py-1">{pod.restarts}</td>
|
||||
<td className="py-1 text-start">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
|
||||
<td className="py-1 text-start">{pod.restarts}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -15,29 +15,19 @@ import {
|
||||
} from '@/components/deleting-overlay';
|
||||
import { filterApplications } from '@/lib/product-type';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import {
|
||||
deploymentStatusBadgeClass,
|
||||
deploymentStatusLabel,
|
||||
lifecycleStatusClass,
|
||||
} from '@/lib/app-list-utils';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
const statusColors = deploymentStatusBadgeClass;
|
||||
const lifecycleColors = lifecycleStatusClass;
|
||||
|
||||
type AppsDict = Dictionary['dashboard']['apps'];
|
||||
|
||||
function statusLabel(status: string, t: Dictionary): string {
|
||||
return (t.dashboard.status as Record<string, string>)[status] ?? status;
|
||||
return deploymentStatusLabel(status, t);
|
||||
}
|
||||
|
||||
function lifecycleLabel(lifecycle: string, a: AppsDict): string {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import { Select } from '@/components/ui/select';
|
||||
@@ -402,7 +404,7 @@ export default function DeployPage() {
|
||||
|
||||
// Wallet balance for the review step payment
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step >= 2,
|
||||
});
|
||||
@@ -492,7 +494,7 @@ export default function DeployPage() {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
||||
callbackUrl: localizedCallbackUrl('/dashboard/deploy', locale),
|
||||
});
|
||||
|
||||
// In production, redirect to gw.gatewayUrl
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
|
||||
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
|
||||
@@ -44,7 +46,7 @@ export default function InvoicesPage() {
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -71,8 +73,8 @@ export default function InvoicesPage() {
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||
};
|
||||
|
||||
const verifyGatewayMutation = useMutation({
|
||||
@@ -99,7 +101,7 @@ export default function InvoicesPage() {
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const callbackUrl = `${window.location.origin}/dashboard/invoices`;
|
||||
const callbackUrl = localizedCallbackUrl('/dashboard/invoices', locale);
|
||||
const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl });
|
||||
if (data.gatewayUrl && data.gatewayAmount > 0) {
|
||||
window.location.href = data.gatewayUrl;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
@@ -151,7 +152,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
// Fetch wallet balance for all authenticated users (shown in header)
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60000,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="min-h-[40vh] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
@@ -103,7 +104,7 @@ export default function ManagedServiceDetailPage() {
|
||||
});
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
@@ -134,7 +136,7 @@ export default function NewManagedServicePage() {
|
||||
});
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step === 2,
|
||||
});
|
||||
@@ -236,7 +238,7 @@ export default function NewManagedServicePage() {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
|
||||
callbackUrl: `${window.location.origin}/dashboard/services/new`,
|
||||
callbackUrl: localizedCallbackUrl('/dashboard/services/new'),
|
||||
});
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: gw.trackingCode,
|
||||
|
||||
@@ -16,13 +16,9 @@ import {
|
||||
} from '@/components/deleting-overlay';
|
||||
import { filterManagedServices } from '@/lib/product-type';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import { lifecycleStatusClass } from '@/lib/app-list-utils';
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
const lifecycleColors = lifecycleStatusClass;
|
||||
|
||||
type AppsDict = Dictionary['dashboard']['apps'];
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import type { WalletTransaction, TransactionType } from '@/types';
|
||||
import { Link } from '@/i18n/Link';
|
||||
@@ -32,12 +34,12 @@ export default function WalletPage() {
|
||||
const [showCharge, setShowCharge] = useState(false);
|
||||
|
||||
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
||||
queryKey: ['wallet-transactions'],
|
||||
queryKey: queryKeys.walletTransactions,
|
||||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -46,8 +48,8 @@ export default function WalletPage() {
|
||||
mutationFn: (amount: number) =>
|
||||
api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||
notify.success(w.chargedSuccess);
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
@@ -61,7 +63,7 @@ export default function WalletPage() {
|
||||
const { data } = await api.post('/billing/gateway/initiate', {
|
||||
amount,
|
||||
description: w.topUpGatewayDesc,
|
||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
||||
callbackUrl: localizedCallbackUrl('/dashboard/wallet', locale),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
@@ -72,8 +74,8 @@ export default function WalletPage() {
|
||||
trackingCode: data.trackingCode,
|
||||
amount: Number(chargeAmount),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||
notify.success(w.paymentSuccess);
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
@@ -114,7 +115,7 @@ export function ManagedServiceResourcesPanel({
|
||||
const isDatabase = app.productType === 'managed_database';
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryKey: queryKeys.walletBalance,
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
@@ -181,7 +182,7 @@ export function ManagedServiceResourcesPanel({
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
setPendingUpgradePayload(null);
|
||||
|
||||
@@ -29,7 +29,13 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
type BadgeTone = 'gray' | 'green' | 'yellow' | 'red' | 'blue' | 'purple';
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
gray: 'badge-gray',
|
||||
green: 'badge-green',
|
||||
yellow: 'badge-yellow',
|
||||
red: 'badge-red',
|
||||
blue: 'badge-blue',
|
||||
purple: 'badge-purple',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = 'gray',
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: BadgeTone;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <span className={clsx('badge', toneClass[tone], className)}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
|
||||
const variantClass: Record<ButtonVariant, string> = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
danger: 'btn-danger',
|
||||
ghost: 'btn-ghost',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
|
||||
return (
|
||||
<button type="button" className={clsx(variantClass[variant], className)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={clsx('card', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1482,7 +1482,7 @@ const en: Dictionary = {
|
||||
platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide',
|
||||
removeCustomDomain: 'Remove Custom Domain',
|
||||
resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show',
|
||||
liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready',
|
||||
liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready', restarts: 'Restarts',
|
||||
noMetrics: 'No metrics yet', storageUsage: 'Storage Usage', loadingStorageMetrics: 'Loading storage metrics...',
|
||||
noStorageData: 'No storage data available', storageMetricsUnavailable: 'Storage metrics unavailable',
|
||||
used: 'Used', applyChanges: 'Apply Changes', applying: 'Applying...', processing: 'Processing...',
|
||||
|
||||
@@ -1488,7 +1488,7 @@ const fa = {
|
||||
removeCustomDomain: 'حذف دامنهٔ اختصاصی',
|
||||
// resources
|
||||
resourcesScaling: 'منابع و مقیاسبندی', monitor: 'پایش', hide: 'پنهان', show: 'نمایش',
|
||||
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریکها…', metrics: 'متریکها', pods: 'پادها', ready: 'آماده',
|
||||
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریکها…', metrics: 'متریکها', pods: 'پادها', ready: 'آماده', restarts: 'ریاستارت',
|
||||
noMetrics: 'هنوز متریکی نیست', storageUsage: 'مصرف فضای ذخیره', loadingStorageMetrics: 'در حال بارگذاری متریکهای فضای ذخیره…',
|
||||
noStorageData: 'دادهی فضای ذخیره موجود نیست', storageMetricsUnavailable: 'متریک فضای ذخیره در دسترس نیست',
|
||||
used: 'مصرفشده', applyChanges: 'اعمال تغییرات', applying: 'در حال اعمال…', processing: 'در حال پردازش…',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { loginPath } from '@/lib/locale-url';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
@@ -55,7 +56,7 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = loginPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||
|
||||
export const deploymentStatusBadgeClass: Record<string, string> = {
|
||||
pending: 'badge-gray',
|
||||
building: 'badge-yellow',
|
||||
deploying: 'badge-blue',
|
||||
running: 'badge-green',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
export const lifecycleStatusClass: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
export function deploymentStatusLabel(status: string, t: Dictionary): string {
|
||||
return (t.dashboard.status as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { classifyApiError } from './errors';
|
||||
|
||||
describe('classifyApiError', () => {
|
||||
it('classifies network errors', () => {
|
||||
const err = new axios.AxiosError('Network Error');
|
||||
expect(classifyApiError(err).kind).toBe('network');
|
||||
});
|
||||
|
||||
it('classifies 403 forbidden', () => {
|
||||
const err = new axios.AxiosError('Forbidden', undefined, undefined, undefined, {
|
||||
status: 403,
|
||||
data: { message: 'Forbidden' },
|
||||
statusText: 'Forbidden',
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
});
|
||||
expect(classifyApiError(err).kind).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('classifies generic errors', () => {
|
||||
expect(classifyApiError(new Error('oops')).kind).toBe('generic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatRemainingForLocale,
|
||||
isPersianLocale,
|
||||
parseCpuToMillicores,
|
||||
} from './format-utils';
|
||||
|
||||
describe('isPersianLocale', () => {
|
||||
it('matches fa-IR and legacy fa', () => {
|
||||
expect(isPersianLocale('fa-IR')).toBe(true);
|
||||
expect(isPersianLocale('fa')).toBe(true);
|
||||
expect(isPersianLocale('en-US')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRemainingForLocale', () => {
|
||||
it('uses Persian formatting for fa-IR', () => {
|
||||
const future = new Date(Date.now() + 2 * 86400000 + 3600000);
|
||||
const result = formatRemainingForLocale(future, 'fa-IR');
|
||||
expect(result).toMatch(/روز/);
|
||||
});
|
||||
|
||||
it('uses English formatting for en-US', () => {
|
||||
const future = new Date(Date.now() + 2 * 86400000);
|
||||
const result = formatRemainingForLocale(future, 'en-US');
|
||||
expect(result).toMatch(/\d+d/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCpuToMillicores', () => {
|
||||
it('parses millicores and cores', () => {
|
||||
expect(parseCpuToMillicores('500m')).toBe(500);
|
||||
expect(parseCpuToMillicores('1')).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,10 @@ export function parseMemoryToMi(mem: string): number {
|
||||
return parseFloat(mem);
|
||||
}
|
||||
|
||||
/** Human-readable time left (days, hours, minutes). */
|
||||
/** True when locale is Persian (fa-IR or legacy "fa"). */
|
||||
export function isPersianLocale(locale: string): boolean {
|
||||
return locale === 'fa' || locale.startsWith('fa-');
|
||||
}
|
||||
export function formatRemainingDurationMs(remainingMs: number): string {
|
||||
const ms = Math.max(0, remainingMs);
|
||||
const days = Math.floor(ms / 86400000);
|
||||
@@ -54,7 +57,7 @@ export function formatRemainingForLocale(
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const remainingMs = date.getTime() - Date.now();
|
||||
return locale === 'fa'
|
||||
return isPersianLocale(locale)
|
||||
? formatRemainingDurationFa(remainingMs)
|
||||
: formatRemainingDurationMs(remainingMs);
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function formatExpiresAtLocal(
|
||||
): string {
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
if (locale === 'fa') {
|
||||
if (isPersianLocale(locale || '')) {
|
||||
// Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
|
||||
// of the runtime's ICU pattern data.
|
||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getClientLocale, localizedPath, loginPath } from './locale-url';
|
||||
import { defaultLocale, LOCALE_COOKIE } from '@/i18n/config';
|
||||
|
||||
describe('locale-url', () => {
|
||||
const originalDocument = global.document;
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom-less: stub document.cookie
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: { cookie: '' },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: originalDocument,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default locale when cookie is missing', () => {
|
||||
expect(getClientLocale()).toBe(defaultLocale);
|
||||
});
|
||||
|
||||
it('reads locale from NEXT_LOCALE cookie', () => {
|
||||
document.cookie = `${LOCALE_COOKIE}=en-US`;
|
||||
expect(getClientLocale()).toBe('en-US');
|
||||
});
|
||||
|
||||
it('builds localized paths', () => {
|
||||
expect(localizedPath('/dashboard/wallet', 'fa-IR')).toBe('/fa-IR/dashboard/wallet');
|
||||
expect(loginPath('en-US')).toBe('/en-US/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defaultLocale, isLocale, LOCALE_COOKIE, type Locale } from '@/i18n/config';
|
||||
|
||||
/** Read the active locale from cookie (client-only). */
|
||||
export function getClientLocale(): Locale {
|
||||
if (typeof document === 'undefined') {
|
||||
return defaultLocale;
|
||||
}
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${LOCALE_COOKIE}=([^;]*)`));
|
||||
const value = match ? decodeURIComponent(match[1]) : '';
|
||||
return isLocale(value) ? value : defaultLocale;
|
||||
}
|
||||
|
||||
/** Build an absolute URL with the locale prefix, e.g. /fa-IR/dashboard/wallet */
|
||||
export function localizedPath(path: string, locale?: Locale): string {
|
||||
const loc = locale ?? getClientLocale();
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `/${loc}${normalized}`;
|
||||
}
|
||||
|
||||
/** Absolute origin + localized path for payment gateway callbacks. */
|
||||
export function localizedCallbackUrl(path: string, locale?: Locale): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return localizedPath(path, locale);
|
||||
}
|
||||
return `${window.location.origin}${localizedPath(path, locale)}`;
|
||||
}
|
||||
|
||||
/** Localized login path for auth redirects. */
|
||||
export function loginPath(locale?: Locale): string {
|
||||
return localizedPath('/login', locale);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Central React Query keys — keep invalidation consistent across pages. */
|
||||
export const queryKeys = {
|
||||
walletBalance: ['wallet-balance'] as const,
|
||||
walletTransactions: ['wallet-transactions'] as const,
|
||||
applications: (productType?: string) =>
|
||||
productType ? (['applications', productType] as const) : (['applications'] as const),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user