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 **[ARCHITECTURE.md](ARCHITECTURE.md)** for detailed system design.
|
||||||
> 🔼 See [UPGRADE.en.md](UPGRADE.en.md) ([فارسی](UPGRADE.md)) for dependency-upgrade notes.
|
> 🔼 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
|
## API
|
||||||
|
|
||||||
All endpoints are prefixed with `/api/v1`. Interactive Swagger docs at
|
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,
|
refresh), `applications`, `deployments`, `clusters`, `billing` (wallet, invoices,
|
||||||
transactions, pricing), `snapshots`, `tickets`, `users`, `admin`, `notifications`.
|
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_HOST=localhost
|
||||||
REDIS_PORT=6379
|
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 ────────────────────────────────────────────────────────────────
|
# ─── OTP SMS ────────────────────────────────────────────────────────────────
|
||||||
# Pick the provider. Without valid credentials, OTP codes are logged to the API
|
# 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
|
# 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
|
mountPath: /app/uploads
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/docs
|
path: /api/v1/health
|
||||||
port: 4000
|
port: 4000
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 15
|
periodSeconds: 15
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/docs
|
path: /api/v1/ready
|
||||||
port: 4000
|
port: 4000
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
periodSeconds: 10
|
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:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
image: postgres:16-alpine
|
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/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.26",
|
"@nestjs/platform-express": "^11.1.26",
|
||||||
"@nestjs/swagger": "^11.4.4",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bull": "^4.12.0",
|
"bull": "^4.12.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
"js-yaml": "^4.2.0",
|
"js-yaml": "^4.2.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -1347,9 +1349,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ioredis/commands": {
|
"node_modules/@ioredis/commands": {
|
||||||
"version": "1.5.1",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
|
||||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"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": {
|
"node_modules/@nestjs/typeorm": {
|
||||||
"version": "11.0.1",
|
"version": "11.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz",
|
||||||
@@ -4896,9 +4909,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6661,20 +6674,18 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.10.1",
|
"version": "5.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
|
||||||
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ioredis/commands": "1.5.1",
|
"@ioredis/commands": "1.10.0",
|
||||||
"cluster-key-slot": "^1.1.0",
|
"cluster-key-slot": "1.1.1",
|
||||||
"debug": "^4.3.4",
|
"debug": "4.4.3",
|
||||||
"denque": "^2.1.0",
|
"denque": "2.1.0",
|
||||||
"lodash.defaults": "^4.2.0",
|
"redis-errors": "1.2.0",
|
||||||
"lodash.isarguments": "^3.1.0",
|
"redis-parser": "3.0.0",
|
||||||
"redis-errors": "^1.2.0",
|
"standard-as-callback": "2.1.0"
|
||||||
"redis-parser": "^3.0.0",
|
|
||||||
"standard-as-callback": "^2.1.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22.0"
|
"node": ">=12.22.0"
|
||||||
@@ -7868,24 +7879,12 @@
|
|||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
"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:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"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\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -18,7 +20,8 @@
|
|||||||
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
|
"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: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",
|
"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": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.26",
|
"@nestjs/platform-express": "^11.1.26",
|
||||||
"@nestjs/swagger": "^11.4.4",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bull": "^4.12.0",
|
"bull": "^4.12.0",
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
"js-yaml": "^4.2.0",
|
"js-yaml": "^4.2.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -69,14 +74,23 @@
|
|||||||
"typescript": "^6.0.0"
|
"typescript": "^6.0.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { ApplicationsModule } from './applications/applications.module';
|
import { ApplicationsModule } from './applications/applications.module';
|
||||||
@@ -15,6 +17,7 @@ import { SnapshotsModule } from './snapshots/snapshots.module';
|
|||||||
import { LifecycleModule } from './lifecycle/lifecycle.module';
|
import { LifecycleModule } from './lifecycle/lifecycle.module';
|
||||||
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
|
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -54,6 +57,14 @@ import configuration from './config/configuration';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: 120,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
// Feature modules
|
// Feature modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -68,6 +79,13 @@ import configuration from './config/configuration';
|
|||||||
LifecycleModule,
|
LifecycleModule,
|
||||||
ApplicationMigrationsModule,
|
ApplicationMigrationsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { ApplicationsService } from './applications.service';
|
import { ApplicationsService } from './applications.service';
|
||||||
import { DomainService } from './domain.service';
|
import { DomainService } from './domain.service';
|
||||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
|
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
|
||||||
@@ -67,6 +68,7 @@ export class ApplicationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/upload')
|
@Post(':id/upload')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.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';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
|
@Throttle({ default: { limit: 20, ttl: 60_000 } })
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
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,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Inject,
|
Inject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
|
import { BillingOpsService } from './billing-ops.service';
|
||||||
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||||
import { ApplicationsService } from '../applications/applications.service';
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
|
||||||
import {
|
import {
|
||||||
ChargeWalletDto,
|
|
||||||
CalculateCostDto,
|
CalculateCostDto,
|
||||||
CalculateDeployCostDto,
|
CalculateDeployCostDto,
|
||||||
SetOptionalServicesPricingDto,
|
SetOptionalServicesPricingDto,
|
||||||
RenewApplicationDto,
|
RenewApplicationDto,
|
||||||
UpgradeResourcesDto,
|
UpgradeResourcesDto,
|
||||||
CalculateUpgradeCostDto,
|
CalculateUpgradeCostDto,
|
||||||
InitiateInvoicePaymentDto,
|
|
||||||
VerifyInvoiceGatewayDto,
|
|
||||||
UpdateInvoiceStatusDto,
|
|
||||||
PayApplicationDto,
|
|
||||||
} from './dto/billing.dto';
|
} from './dto/billing.dto';
|
||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
@@ -41,12 +33,7 @@ import {
|
|||||||
BillingCycle,
|
BillingCycle,
|
||||||
AppLifecycleStatus,
|
AppLifecycleStatus,
|
||||||
InvoiceReason,
|
InvoiceReason,
|
||||||
InvoiceStatus,
|
|
||||||
PaymentMethod,
|
|
||||||
ProductType,
|
|
||||||
DatabaseType,
|
|
||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
|
||||||
|
|
||||||
@ApiTags('Billing')
|
@ApiTags('Billing')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -55,12 +42,11 @@ import { Application } from '../applications/entities/application.entity';
|
|||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
|
private readonly billingOpsService: BillingOpsService,
|
||||||
@Inject(forwardRef(() => AppLifecycleService))
|
@Inject(forwardRef(() => AppLifecycleService))
|
||||||
private readonly lifecycleService: AppLifecycleService,
|
private readonly lifecycleService: AppLifecycleService,
|
||||||
@Inject(forwardRef(() => ApplicationsService))
|
@Inject(forwardRef(() => ApplicationsService))
|
||||||
private readonly applicationsService: ApplicationsService,
|
private readonly applicationsService: ApplicationsService,
|
||||||
@Inject(forwardRef(() => KubernetesService))
|
|
||||||
private readonly kubernetesService: KubernetesService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||||
@@ -152,274 +138,6 @@ export class BillingController {
|
|||||||
return this.billingService.setOptionalServicesPricing(dto);
|
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 ──────────────────────────────────────────
|
// ─── Application Renewal ──────────────────────────────────────────
|
||||||
|
|
||||||
@Get('applications/:applicationId/renewal-cost')
|
@Get('applications/:applicationId/renewal-cost')
|
||||||
@@ -428,8 +146,7 @@ export class BillingController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
) {
|
) {
|
||||||
// User can only view their own app, admin/sales can view any
|
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
|
||||||
const costs = await this.billingService.calculateRenewalCost(app);
|
const costs = await this.billingService.calculateRenewalCost(app);
|
||||||
return {
|
return {
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -448,7 +165,7 @@ export class BillingController {
|
|||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() dto: RenewApplicationDto,
|
@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 costs = await this.billingService.calculateRenewalCost(app);
|
||||||
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
||||||
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||||
@@ -490,10 +207,8 @@ export class BillingController {
|
|||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() dto: RenewApplicationDto,
|
@Body() dto: RenewApplicationDto,
|
||||||
) {
|
) {
|
||||||
// User can only renew their own app, admin/sales can renew any
|
const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId);
|
||||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
|
||||||
|
|
||||||
// Calculate cost for the selected cycle
|
|
||||||
const costs = await this.billingService.calculateRenewalCost(app);
|
const costs = await this.billingService.calculateRenewalCost(app);
|
||||||
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
||||||
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||||
@@ -533,7 +248,6 @@ export class BillingController {
|
|||||||
|
|
||||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||||
|
|
||||||
// Activate the application
|
|
||||||
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
|
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -567,7 +281,6 @@ export class BillingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.bypassPayment) {
|
if (body.bypassPayment) {
|
||||||
// Direct activation without payment (for special cases, support, etc.)
|
|
||||||
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
|
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
|
||||||
return {
|
return {
|
||||||
success: true,
|
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 costs = await this.billingService.calculateRenewalCost(app);
|
||||||
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
||||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||||
@@ -640,7 +352,7 @@ export class BillingController {
|
|||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() dto: CalculateUpgradeCostDto,
|
@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);
|
const result = await this.billingService.calculateUpgradeCost(app, dto);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -675,7 +387,7 @@ export class BillingController {
|
|||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() dto: UpgradeResourcesDto,
|
@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) {
|
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
|
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
|
||||||
@@ -727,20 +439,17 @@ export class BillingController {
|
|||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() dto: UpgradeResourcesDto,
|
@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) {
|
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`
|
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate upgrade cost
|
|
||||||
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
|
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
|
||||||
let paidInvoice = null;
|
let paidInvoice = null;
|
||||||
|
|
||||||
// If upgrading (positive difference), require payment
|
|
||||||
if (costResult.proratedAmount > 0) {
|
if (costResult.proratedAmount > 0) {
|
||||||
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
|
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
|
||||||
? app.userId
|
? app.userId
|
||||||
@@ -784,11 +493,11 @@ export class BillingController {
|
|||||||
const updatedApp = await this.applicationsService.update(
|
const updatedApp = await this.applicationsService.update(
|
||||||
app.id,
|
app.id,
|
||||||
app.userId,
|
app.userId,
|
||||||
this.buildUpgradeEntityPatch(app, dto),
|
this.billingOpsService.buildUpgradeEntityPatch(app, dto),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.applyUpgradeToKubernetes(updatedApp, dto, app);
|
await this.billingOpsService.applyUpgradeToKubernetes(updatedApp, dto, app);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
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).',
|
: '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 { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
|
import { BillingOpsService } from './billing-ops.service';
|
||||||
import { BillingController } from './billing.controller';
|
import { BillingController } from './billing.controller';
|
||||||
|
import { BillingWalletController } from './billing-wallet.controller';
|
||||||
|
import { BillingInvoicesController } from './billing-invoices.controller';
|
||||||
import { PublicPricingController } from './public-pricing.controller';
|
import { PublicPricingController } from './public-pricing.controller';
|
||||||
import { DiscountController } from './discount.controller';
|
import { DiscountController } from './discount.controller';
|
||||||
import { DiscountService } from './discount.service';
|
import { DiscountService } from './discount.service';
|
||||||
@@ -42,8 +45,14 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
|||||||
forwardRef(() => ApplicationsModule),
|
forwardRef(() => ApplicationsModule),
|
||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PublicPricingController, DiscountController],
|
controllers: [
|
||||||
providers: [BillingService, PricingCatalogService, DiscountService],
|
BillingController,
|
||||||
exports: [BillingService, PricingCatalogService, DiscountService],
|
BillingWalletController,
|
||||||
|
BillingInvoicesController,
|
||||||
|
PublicPricingController,
|
||||||
|
DiscountController,
|
||||||
|
],
|
||||||
|
providers: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
|
||||||
|
exports: [BillingService, BillingOpsService, PricingCatalogService, DiscountService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
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 { Module, forwardRef } from '@nestjs/common';
|
||||||
import { BuildService } from './build.service';
|
import { BuildService } from './build.service';
|
||||||
|
import { BuildProgressStore } from './build-progress.store';
|
||||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
import { ClustersModule } from '../clusters/clusters.module';
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module';
|
|||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
ClustersModule,
|
ClustersModule,
|
||||||
],
|
],
|
||||||
providers: [BuildService],
|
providers: [BuildService, BuildProgressStore],
|
||||||
exports: [BuildService],
|
exports: [BuildService],
|
||||||
})
|
})
|
||||||
export class BuildModule {}
|
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 { AppRuntime } from '../common/enums';
|
||||||
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
import { RegistryService } from '../kubernetes/registry.service';
|
||||||
|
import { BuildProgressStore } from './build-progress.store';
|
||||||
|
|
||||||
/**
|
describe('BuildService', () => {
|
||||||
* Tests for build service — Dockerfile generation for all runtimes
|
let service: BuildService;
|
||||||
*/
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
beforeEach(async () => {
|
||||||
// Go Dockerfile tests
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
providers: [
|
||||||
describe('Go Dockerfile generation', () => {
|
BuildService,
|
||||||
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
{
|
||||||
const goVersion = app.runtimeVersion || '1.22';
|
provide: ConfigService,
|
||||||
const port = app.port || 8080;
|
useValue: {
|
||||||
return `FROM golang:${goVersion}-alpine AS builder
|
get: jest.fn((key: string) => {
|
||||||
WORKDIR /app
|
const map: Record<string, string> = {
|
||||||
RUN apk add --no-cache git
|
'build.namespace': 'cloudhost-builds',
|
||||||
COPY go.mod go.sum* ./
|
'build.serviceAccount': 'kaniko-builder',
|
||||||
RUN go mod download || true
|
'registry.url': 'registry.local:5000',
|
||||||
COPY . .
|
};
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
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
|
service = module.get(BuildService);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should default to Go 1.22', () => {
|
describe('generateDockerfile', () => {
|
||||||
const df = goDockerfile({});
|
it('generates Go Dockerfile with requested runtime version', () => {
|
||||||
expect(df).toContain('FROM golang:1.22-alpine');
|
const app = {
|
||||||
});
|
runtime: AppRuntime.GO,
|
||||||
|
runtimeVersion: '1.22',
|
||||||
|
port: 8080,
|
||||||
|
} as Application;
|
||||||
|
|
||||||
it('should build static binary with CGO_ENABLED=0', () => {
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
const df = goDockerfile({});
|
|
||||||
expect(df).toContain('CGO_ENABLED=0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use multi-stage build for smaller image', () => {
|
expect(dockerfile).toContain('FROM golang:1.22-alpine');
|
||||||
const df = goDockerfile({});
|
expect(dockerfile).toContain('EXPOSE 8080');
|
||||||
expect(df).toContain('AS builder');
|
});
|
||||||
expect(df).toContain('FROM alpine:3.19');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include health check', () => {
|
it('generates Node.js Dockerfile with default port', () => {
|
||||||
const df = goDockerfile({ port: 8080 });
|
const app = {
|
||||||
expect(df).toContain('HEALTHCHECK');
|
runtime: AppRuntime.NODEJS,
|
||||||
expect(df).toContain('http://localhost:8080/health');
|
runtimeVersion: '20',
|
||||||
});
|
} as Application;
|
||||||
|
|
||||||
it('should create data directory for persistent storage', () => {
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
const df = goDockerfile({});
|
|
||||||
expect(df).toContain('mkdir -p /app/data');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should run as non-root user', () => {
|
expect(dockerfile).toContain('FROM node:20');
|
||||||
const df = goDockerfile({});
|
expect(dockerfile).toContain('EXPOSE 3000');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Application } from '../applications/entities/application.entity';
|
|||||||
import { AppRuntime } from '../common/enums';
|
import { AppRuntime } from '../common/enums';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
import { RegistryService } from '../kubernetes/registry.service';
|
import { RegistryService } from '../kubernetes/registry.service';
|
||||||
|
import { BuildProgressStore } from './build-progress.store';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export class BuildService {
|
|||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
private registryService: RegistryService,
|
private registryService: RegistryService,
|
||||||
|
private progressStore: BuildProgressStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private beginBuildSession(deploymentId: string): void {
|
private beginBuildSession(deploymentId: string): void {
|
||||||
@@ -277,17 +279,23 @@ export class BuildService {
|
|||||||
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
|
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getProgress(deploymentId: string): BuildProgress | null {
|
async getProgress(deploymentId: string): Promise<BuildProgress | null> {
|
||||||
return this.progressMap.get(deploymentId) ?? 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 {
|
setProgress(deploymentId: string | undefined, progress: BuildProgress): void {
|
||||||
if (!deploymentId) return;
|
if (!deploymentId) return;
|
||||||
this.progressMap.set(deploymentId, progress);
|
this.progressMap.set(deploymentId, progress);
|
||||||
|
void this.progressStore.set(deploymentId, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearProgress(deploymentId: string): void {
|
clearProgress(deploymentId: string): void {
|
||||||
this.progressMap.delete(deploymentId);
|
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 { ClusterStatus } from '../common/enums';
|
||||||
|
import { RegistryService } from '../kubernetes/registry.service';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
/**
|
describe('ClustersService', () => {
|
||||||
* Tests for ClustersService — getDefault and delete logic.
|
let service: ClustersService;
|
||||||
*/
|
|
||||||
|
|
||||||
describe('ClustersService getDefault logic', () => {
|
const clustersRepository = {
|
||||||
// Simulate the fixed getDefault behavior
|
findOne: jest.fn(),
|
||||||
function getDefault(clusters: { id: string; isDefault: boolean; status: string }[]): { id: string } | null {
|
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
|
||||||
// Step 1: active + default
|
find: jest.fn(),
|
||||||
let result = clusters.find(c => c.isDefault && c.status === ClusterStatus.ACTIVE);
|
create: jest.fn(),
|
||||||
if (result) return { id: result.id };
|
delete: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
// Step 2: any active (fallback)
|
const healthRepository = { find: jest.fn(), save: jest.fn() };
|
||||||
result = clusters.find(c => c.status === ClusterStatus.ACTIVE);
|
const poolRepository = { find: jest.fn(), findOne: jest.fn(), save: jest.fn() };
|
||||||
if (result) return { id: result.id };
|
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 module: TestingModule = await Test.createTestingModule({
|
||||||
const clusters = [
|
providers: [
|
||||||
{ id: '1', isDefault: true, status: ClusterStatus.ACTIVE },
|
ClustersService,
|
||||||
{ id: '2', isDefault: false, status: ClusterStatus.ACTIVE },
|
{ provide: getRepositoryToken(Cluster), useValue: clustersRepository },
|
||||||
];
|
{ provide: getRepositoryToken(ClusterPool), useValue: poolRepository },
|
||||||
expect(getDefault(clusters)?.id).toBe('1');
|
{ 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', () => {
|
describe('getDefault', () => {
|
||||||
const clusters = [
|
it('returns active default cluster', async () => {
|
||||||
{ id: '1', isDefault: true, status: ClusterStatus.INACTIVE },
|
const cluster = {
|
||||||
{ id: '2', isDefault: false, status: ClusterStatus.ACTIVE },
|
id: 'c-1',
|
||||||
];
|
name: 'primary',
|
||||||
expect(getDefault(clusters)?.id).toBe('2');
|
isDefault: true,
|
||||||
});
|
status: ClusterStatus.ACTIVE,
|
||||||
|
kubeconfig: 'apiVersion: v1',
|
||||||
|
} as Cluster;
|
||||||
|
|
||||||
it('should return null when no active clusters exist', () => {
|
clustersRepository.findOne.mockResolvedValueOnce(cluster);
|
||||||
const clusters = [
|
|
||||||
{ id: '1', isDefault: true, status: ClusterStatus.INACTIVE },
|
|
||||||
];
|
|
||||||
expect(getDefault(clusters)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle both clusters being default (picks active one)', () => {
|
const result = await service.getDefault();
|
||||||
const clusters = [
|
|
||||||
{ id: 'inactive', isDefault: true, status: ClusterStatus.INACTIVE },
|
expect(result.id).toBe('c-1');
|
||||||
{ id: 'active', isDefault: true, status: ClusterStatus.ACTIVE },
|
expect(clustersRepository.findOne).toHaveBeenCalledWith({
|
||||||
];
|
where: { isDefault: true, status: ClusterStatus.ACTIVE },
|
||||||
expect(getDefault(clusters)?.id).toBe('active');
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it('falls back to any active cluster when no default is set', async () => {
|
||||||
describe('ClustersService delete logic', () => {
|
const fallback = {
|
||||||
it('should reassign apps to replacement cluster on delete', () => {
|
id: 'c-2',
|
||||||
// Simulate: cluster A (being deleted) has 3 apps, cluster B is active
|
name: 'fallback',
|
||||||
const apps = [
|
isDefault: false,
|
||||||
{ id: 'app1', clusterId: 'A' },
|
status: ClusterStatus.ACTIVE,
|
||||||
{ id: 'app2', clusterId: 'A' },
|
kubeconfig: 'apiVersion: v1',
|
||||||
{ id: 'app3', clusterId: 'B' },
|
} as Cluster;
|
||||||
];
|
|
||||||
const deletedClusterId = 'A';
|
clustersRepository.findOne
|
||||||
const replacementId = 'B';
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(fallback);
|
||||||
// Reassign
|
|
||||||
for (const app of apps) {
|
const result = await service.getDefault();
|
||||||
if (app.clusterId === deletedClusterId) {
|
|
||||||
app.clusterId = replacementId;
|
expect(result.id).toBe('c-2');
|
||||||
}
|
expect(clustersRepository.save).toHaveBeenCalled();
|
||||||
}
|
});
|
||||||
|
|
||||||
expect(apps.filter(a => a.clusterId === 'A')).toHaveLength(0);
|
it('throws when no active cluster exists', async () => {
|
||||||
expect(apps.filter(a => a.clusterId === 'B')).toHaveLength(3);
|
clustersRepository.findOne.mockResolvedValue(null);
|
||||||
});
|
|
||||||
|
await expect(service.getDefault()).rejects.toThrow(NotFoundException);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export default () => ({
|
|||||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
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').
|
// OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar').
|
||||||
sms: {
|
sms: {
|
||||||
provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(),
|
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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { DeploymentsService } from './deployments.service';
|
import { DeploymentsService } from './deployments.service';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
|
||||||
@ApiTags('Deployments')
|
@ApiTags('Deployments')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -18,6 +19,12 @@ import { RolesGuard } from '../common/guards/roles.guard';
|
|||||||
export class DeploymentsController {
|
export class DeploymentsController {
|
||||||
constructor(private readonly deploymentsService: DeploymentsService) {}
|
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')
|
@Post('applications/:appId/deploy')
|
||||||
@ApiOperation({ summary: 'Trigger a new deployment' })
|
@ApiOperation({ summary: 'Trigger a new deployment' })
|
||||||
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
|
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
|
||||||
@@ -26,14 +33,14 @@ export class DeploymentsController {
|
|||||||
|
|
||||||
@Get('applications/:appId')
|
@Get('applications/:appId')
|
||||||
@ApiOperation({ summary: 'List deployments for an application' })
|
@ApiOperation({ summary: 'List deployments for an application' })
|
||||||
async findByApplication(@Param('appId') appId: string) {
|
async findByApplication(@Param('appId') appId: string, @Request() req: any) {
|
||||||
return this.deploymentsService.findByApplication(appId);
|
return this.deploymentsService.findByApplication(appId, this.ownershipUserId(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get deployment details' })
|
@ApiOperation({ summary: 'Get deployment details' })
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||||
return this.deploymentsService.findOne(id);
|
return this.deploymentsService.findOne(id, this.ownershipUserId(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('applications/:appId/logs')
|
@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({
|
return this.deploymentsRepository.find({
|
||||||
where: { applicationId },
|
where: { applicationId },
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<Deployment> {
|
async findOne(id: string, userId?: string): Promise<Deployment> {
|
||||||
const deployment = await this.deploymentsRepository.findOne({
|
const deployment = await this.deploymentsRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: { application: true },
|
relations: { application: true },
|
||||||
@@ -478,6 +479,7 @@ export class DeploymentsService {
|
|||||||
if (!deployment) {
|
if (!deployment) {
|
||||||
throw new NotFoundException('Deployment not found');
|
throw new NotFoundException('Deployment not found');
|
||||||
}
|
}
|
||||||
|
await this.applicationsService.findOne(deployment.applicationId, userId);
|
||||||
return deployment;
|
return deployment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +539,7 @@ export class DeploymentsService {
|
|||||||
|
|
||||||
if (!latest) return null;
|
if (!latest) return null;
|
||||||
|
|
||||||
const progress = this.buildService.getProgress(latest.id);
|
const progress = await this.buildService.getProgress(latest.id);
|
||||||
if (progress) return progress;
|
if (progress) return progress;
|
||||||
|
|
||||||
// No in-memory progress — infer from deployment status
|
// 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();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chartPath', () => {
|
describe('resolveChartPath', () => {
|
||||||
it('should resolve to helm/cloudhost-app relative to project root', () => {
|
it('should resolve to helm/cloudhost-app relative to project root', () => {
|
||||||
const expectedSuffix = path.join('helm', 'cloudhost-app');
|
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 { RegistryService } from './registry.service';
|
||||||
import { KubernetesService } from './kubernetes.service';
|
import { KubernetesService } from './kubernetes.service';
|
||||||
|
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression tests for the @kubernetes/client-node 1.x migration.
|
* Regression tests for the @kubernetes/client-node 1.x migration.
|
||||||
@@ -80,19 +81,25 @@ describe('KubernetesService — k8s v1 client shape', () => {
|
|||||||
let service: KubernetesService;
|
let service: KubernetesService;
|
||||||
|
|
||||||
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
|
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(
|
const svc = new KubernetesService(
|
||||||
configStub,
|
configStub,
|
||||||
{} as any, // clustersService
|
{} as any, // clustersService
|
||||||
{} as any, // helmService
|
{} as any, // helmService
|
||||||
{} as any, // registryService
|
{} as any, // registryService
|
||||||
|
k8sClientService as any,
|
||||||
|
k8sLifecycleService,
|
||||||
{} as any, // deploymentsRepository
|
{} as any, // deploymentsRepository
|
||||||
);
|
);
|
||||||
jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({
|
|
||||||
coreApi: clients.coreApi,
|
|
||||||
appsApi: clients.appsApi,
|
|
||||||
networkingApi: clients.networkingApi,
|
|
||||||
kc: clients.kc,
|
|
||||||
});
|
|
||||||
return svc;
|
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 { KubernetesService } from './kubernetes.service';
|
||||||
import { HelmService } from './helm.service';
|
import { HelmService } from './helm.service';
|
||||||
import { RegistryService } from './registry.service';
|
import { RegistryService } from './registry.service';
|
||||||
|
import { K8sClientService } from './k8s-client.service';
|
||||||
|
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||||
import { ElasticsearchService } from './elasticsearch.service';
|
import { ElasticsearchService } from './elasticsearch.service';
|
||||||
import { ElasticsearchController } from './elasticsearch.controller';
|
import { ElasticsearchController } from './elasticsearch.controller';
|
||||||
import { LogsController } from './logs.controller';
|
import { LogsController } from './logs.controller';
|
||||||
@@ -13,7 +15,7 @@ import { Deployment } from '../deployments/entities/deployment.entity';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])],
|
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])],
|
||||||
controllers: [ElasticsearchController, LogsController],
|
controllers: [ElasticsearchController, LogsController],
|
||||||
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
|
||||||
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
|
||||||
})
|
})
|
||||||
export class KubernetesModule {}
|
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 { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums';
|
||||||
import { HelmService } from './helm.service';
|
import { HelmService } from './helm.service';
|
||||||
import { RegistryService } from './registry.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';
|
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -74,6 +76,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
private helmService: HelmService,
|
private helmService: HelmService,
|
||||||
private registryService: RegistryService,
|
private registryService: RegistryService,
|
||||||
|
private k8sClientService: K8sClientService,
|
||||||
|
private k8sLifecycleService: K8sLifecycleService,
|
||||||
@InjectRepository(Deployment)
|
@InjectRepository(Deployment)
|
||||||
private deploymentsRepository: Repository<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
|
// 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.
|
* 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> {
|
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 namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const managed = isManagedProductType(app.productType);
|
const managed = isManagedProductType(app.productType);
|
||||||
const workloads = [
|
const workloads = [
|
||||||
@@ -463,14 +439,14 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
|
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
|
||||||
|
|
||||||
try {
|
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 imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : '';
|
||||||
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
||||||
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
|
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
|
||||||
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
||||||
} catch (helmError: any) {
|
} catch (helmError: any) {
|
||||||
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
|
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 = {
|
const ctx: ManifestContext = {
|
||||||
appName: app.name,
|
appName: app.name,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -513,9 +489,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
// ── Helm-based deployment ─────────────────────────────────────────
|
// ── Helm-based deployment ─────────────────────────────────────────
|
||||||
|
|
||||||
private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise<Record<string, any>> {
|
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);
|
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 values = this.buildHelmValues(app, imageUri, previewNumber);
|
||||||
const namespace = values.app.namespace as string;
|
const namespace = values.app.namespace as string;
|
||||||
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
||||||
@@ -531,7 +507,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
|
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);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const values = this.buildManagedHelmValues(app);
|
const values = this.buildManagedHelmValues(app);
|
||||||
const namespace = values.app.namespace;
|
const namespace = values.app.namespace;
|
||||||
@@ -549,8 +525,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
// ── Direct K8s API deployment (fallback) ──────────────────────────
|
// ── Direct K8s API deployment (fallback) ──────────────────────────
|
||||||
|
|
||||||
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
|
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
|
||||||
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.ensurePlatformStorageClass(kubeconfig);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const context: ManifestContext = {
|
const context: ManifestContext = {
|
||||||
@@ -619,8 +595,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
if (isManagedProductType(app.productType)) {
|
if (isManagedProductType(app.productType)) {
|
||||||
return this.deployManagedViaK8sApi(app);
|
return this.deployManagedViaK8sApi(app);
|
||||||
}
|
}
|
||||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.ensurePlatformStorageClass(kubeconfig);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
|
|
||||||
@@ -2247,33 +2223,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPodLogs(app: Application): Promise<string> {
|
async getPodLogs(app: Application): Promise<string> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
return this.k8sLifecycleService.getPodLogs(app);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async scaleDeployment(app: Application, replicas: number): Promise<void> {
|
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]}`;
|
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'));
|
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>> {
|
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 namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const snapshot: Record<string, number> = {};
|
const snapshot: Record<string, number> = {};
|
||||||
|
|
||||||
@@ -2342,7 +2296,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Returns the replica snapshot captured before scaling.
|
* Returns the replica snapshot captured before scaling.
|
||||||
*/
|
*/
|
||||||
async suspendApplication(app: Application): Promise<Record<string, number>> {
|
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]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
|
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.
|
* Resume a suspended application using saved replica counts when available.
|
||||||
*/
|
*/
|
||||||
async resumeApplication(app: Application): Promise<void> {
|
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]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
|
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> {
|
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 namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
|
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.
|
* Includes application workload, database, and optional Redis / RabbitMQ when enabled.
|
||||||
*/
|
*/
|
||||||
async getResourceUsage(app: Application): Promise<any> {
|
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 namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const workloads: any[] = [];
|
const workloads: any[] = [];
|
||||||
@@ -2685,7 +2639,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
|
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const target = this.workloadDeploymentTarget(app, workload);
|
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');
|
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 namespace = this.getUserNamespace(app.userId);
|
||||||
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
|
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
|
||||||
const shortId = grantId.split('-')[0];
|
const shortId = grantId.split('-')[0];
|
||||||
@@ -2874,7 +2828,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const manifestPath = path.join(tmpDir, 'service.json');
|
const manifestPath = path.join(tmpDir, 'service.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(kubeconfigPath, await this.getKubeconfig(clusterId), {
|
fs.writeFileSync(kubeconfigPath, await this.k8sClientService.getKubeconfig(clusterId), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(service), { 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> {
|
async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise<void> {
|
||||||
const { coreApi } = await this.getK8sClient(clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(clusterId);
|
||||||
try {
|
try {
|
||||||
await coreApi.deleteNamespacedService({
|
await coreApi.deleteNamespacedService({
|
||||||
name: k8sServiceName,
|
name: k8sServiceName,
|
||||||
@@ -2912,7 +2866,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
|
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
|
||||||
if (!app.clusterId) return;
|
if (!app.clusterId) return;
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const services = await coreApi.listNamespacedService({
|
const services = await coreApi.listNamespacedService({
|
||||||
@@ -2942,7 +2896,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
case ServiceAccessTarget.REDIS: {
|
case ServiceAccessTarget.REDIS: {
|
||||||
if (!app.clusterId) return {};
|
if (!app.clusterId) return {};
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const secret = await coreApi.readNamespacedSecret({
|
const secret = await coreApi.readNamespacedSecret({
|
||||||
name: `${app.name}-redis-secret`,
|
name: `${app.name}-redis-secret`,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -2953,7 +2907,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
case ServiceAccessTarget.RABBITMQ_AMQP:
|
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||||
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
|
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
|
||||||
if (!app.clusterId) return {};
|
if (!app.clusterId) return {};
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const secret = await coreApi.readNamespacedSecret({
|
const secret = await coreApi.readNamespacedSecret({
|
||||||
name: `${app.name}-rabbitmq-secret`,
|
name: `${app.name}-rabbitmq-secret`,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -2980,7 +2934,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
host: string;
|
host: string;
|
||||||
ingressUrl?: 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 namespace = this.getUserNamespace(app.userId);
|
||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
const hostIp = this.getClusterHostIp(kc);
|
const hostIp = this.getClusterHostIp(kc);
|
||||||
@@ -3044,13 +2998,13 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
|
|
||||||
async deleteApplication(app: Application): Promise<void> {
|
async deleteApplication(app: Application): Promise<void> {
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
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);
|
await this.deleteTemporaryAccessServicesForApp(app);
|
||||||
|
|
||||||
// Step 1: Try Helm uninstall (handles most resources)
|
// Step 1: Try Helm uninstall (handles most resources)
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.helmService.uninstall(app.name, namespace, kubeconfig);
|
await this.helmService.uninstall(app.name, namespace, kubeconfig);
|
||||||
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
|
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -3171,8 +3125,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const source = await this.getK8sClient(app.clusterId);
|
const source = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const target = await this.getK8sClient(targetClusterId);
|
const target = await this.k8sClientService.getK8sClient(targetClusterId);
|
||||||
|
|
||||||
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
|
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
|
||||||
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { 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');
|
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
|
||||||
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
fs.writeFileSync(sourceKubeconfig, await this.getKubeconfig(sourceClusterId), { mode: 0o600 });
|
fs.writeFileSync(sourceKubeconfig, await this.k8sClientService.getKubeconfig(sourceClusterId), { mode: 0o600 });
|
||||||
fs.writeFileSync(targetKubeconfig, await this.getKubeconfig(targetClusterId), { mode: 0o600 });
|
fs.writeFileSync(targetKubeconfig, await this.k8sClientService.getKubeconfig(targetClusterId), { mode: 0o600 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
|
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`.
|
* Polls pod status with label selector `app=<appName>-db`.
|
||||||
*/
|
*/
|
||||||
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
|
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 namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const dbLabel = `${app.name}-db`;
|
const dbLabel = `${app.name}-db`;
|
||||||
const start = Date.now();
|
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.
|
* then runs a restore Job that mounts the PVC and imports the dump.
|
||||||
*/
|
*/
|
||||||
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
|
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const dbName = `${app.name}-db`;
|
const dbName = `${app.name}-db`;
|
||||||
@@ -3821,7 +3775,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Used when legacy PVCs were created without storageClassName.
|
* Used when legacy PVCs were created without storageClassName.
|
||||||
*/
|
*/
|
||||||
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
|
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const oldPvcName = `${app.name}-db`;
|
const oldPvcName = `${app.name}-db`;
|
||||||
@@ -3997,7 +3951,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* K8s only supports PVC expansion, not shrinking.
|
* K8s only supports PVC expansion, not shrinking.
|
||||||
*/
|
*/
|
||||||
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-db`;
|
const pvcName = `${app.name}-db`;
|
||||||
|
|
||||||
@@ -4070,7 +4024,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
async getDatabasePvcSize(app: Application): Promise<string> {
|
async getDatabasePvcSize(app: Application): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-db`;
|
const pvcName = `${app.name}-db`;
|
||||||
|
|
||||||
@@ -4096,7 +4050,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
totalAllocatedGb: number;
|
totalAllocatedGb: number;
|
||||||
totalUsedGb: 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 namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
@@ -4271,7 +4225,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Returns usage in GB.
|
* Returns usage in GB.
|
||||||
*/
|
*/
|
||||||
private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise<number> {
|
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({
|
const pods = await coreApi.listNamespacedPod({
|
||||||
namespace,
|
namespace,
|
||||||
@@ -4305,7 +4259,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
|
* 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 }> {
|
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]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4362,7 +4316,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Resize app storage PVC (all app types).
|
* Resize app storage PVC (all app types).
|
||||||
*/
|
*/
|
||||||
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
// Try new unified name first, then legacy wp-content name
|
// 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.
|
* 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 }> {
|
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 batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const dbName = `${app.name}-db`;
|
const dbName = `${app.name}-db`;
|
||||||
@@ -4608,7 +4562,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Strategy: Create archive, then sleep to allow exec retrieval.
|
* Strategy: Create archive, then sleep to allow exec retrieval.
|
||||||
*/
|
*/
|
||||||
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
|
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-storage`;
|
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.
|
* Restore wp-content from a tar.gz archive into the WordPress PVC.
|
||||||
*/
|
*/
|
||||||
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
|
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-storage`;
|
const pvcName = `${app.name}-storage`;
|
||||||
@@ -4902,7 +4856,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const releaseName = app.name;
|
const releaseName = app.name;
|
||||||
|
|
||||||
try {
|
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);
|
const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig);
|
||||||
|
|
||||||
if (!helmRevisions || helmRevisions.length === 0) {
|
if (!helmRevisions || helmRevisions.length === 0) {
|
||||||
@@ -4940,7 +4894,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const releaseName = app.name;
|
const releaseName = app.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
|
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
|
||||||
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
|
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
|
|||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { validateProductionConfig } from './config/validate-production-config';
|
||||||
|
|
||||||
// Prevent Node.js from crashing on unhandled errors
|
// Prevent Node.js from crashing on unhandled errors
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
@@ -15,6 +16,8 @@ process.on('uncaughtException', (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
validateProductionConfig();
|
||||||
|
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['error', 'warn', 'log'],
|
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_PORT: 5432
|
||||||
DB_USERNAME: cloudhost
|
DB_USERNAME: cloudhost
|
||||||
DB_PASSWORD: cloudhost_secret
|
DB_PASSWORD: cloudhost_secret
|
||||||
DB_NAME: cloudhost
|
DB_DATABASE: cloudhost
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET: change-this-to-a-long-random-string
|
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_EXPIRES_IN: 15m
|
||||||
JWT_REFRESH_EXPIRES_IN: 7d
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
|
|
||||||
@@ -73,9 +74,10 @@ services:
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
BUILD_NAMESPACE: cloudhost-builds
|
BUILD_NAMESPACE: cloudhost-builds
|
||||||
KANIKO_IMAGE: gcr.io/kaniko-project/executor:latest
|
KANIKO_IMAGE: gcr.io/kaniko-project/executor:v1.23.2
|
||||||
|
|
||||||
# Platform
|
# Platform
|
||||||
|
FRONTEND_URL: http://localhost:3000
|
||||||
PLATFORM_DOMAIN: apps.localhost
|
PLATFORM_DOMAIN: apps.localhost
|
||||||
volumes:
|
volumes:
|
||||||
- /tmp/cloudhost-uploads:/app/uploads
|
- /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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
@@ -36,8 +39,10 @@
|
|||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.184.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^4.3.1",
|
"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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
|
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
@@ -250,7 +251,7 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
// ─── Billing & Renewal ──────────────────────────────
|
// ─── Billing & Renewal ──────────────────────────────
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ export default function AppDetailPage() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
notify.success(res.data.message || 'Application renewed successfully!');
|
notify.success(res.data.message || 'Application renewed successfully!');
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||||
setShowRenewalModal(false);
|
setShowRenewalModal(false);
|
||||||
setRenewCoupon('');
|
setRenewCoupon('');
|
||||||
},
|
},
|
||||||
@@ -646,7 +647,7 @@ export default function AppDetailPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
setPendingUpgradePayload(null);
|
setPendingUpgradePayload(null);
|
||||||
@@ -2134,22 +2135,22 @@ export default function AppDetailPage() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[11px]">
|
<table className="w-full text-[11px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
<tr className="text-start text-gray-500 border-b border-gray-200">
|
||||||
<th className="pb-1 font-medium">{ad.name}</th>
|
<th className="pb-1 font-medium text-start">{ad.name}</th>
|
||||||
<th className="pb-1 font-medium">{ad.status}</th>
|
<th className="pb-1 font-medium text-start">{ad.status}</th>
|
||||||
<th className="pb-1 font-medium">{ad.ready}</th>
|
<th className="pb-1 font-medium text-start">{ad.ready}</th>
|
||||||
<th className="pb-1 font-medium">R</th>
|
<th className="pb-1 font-medium text-start">{ad.restarts}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{w.pods.map((pod) => (
|
{w.pods.map((pod) => (
|
||||||
<tr key={pod.name} className="text-gray-700">
|
<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 text-start font-mono truncate max-w-[140px]" dir="ltr" title={pod.name}>{pod.name}</td>
|
||||||
<td className="py-1">
|
<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>
|
<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>
|
||||||
<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 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">{pod.restarts}</td>
|
<td className="py-1 text-start">{pod.restarts}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -15,29 +15,19 @@ import {
|
|||||||
} from '@/components/deleting-overlay';
|
} from '@/components/deleting-overlay';
|
||||||
import { filterApplications } from '@/lib/product-type';
|
import { filterApplications } from '@/lib/product-type';
|
||||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||||
|
import {
|
||||||
|
deploymentStatusBadgeClass,
|
||||||
|
deploymentStatusLabel,
|
||||||
|
lifecycleStatusClass,
|
||||||
|
} from '@/lib/app-list-utils';
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors = deploymentStatusBadgeClass;
|
||||||
running: 'badge-green',
|
const lifecycleColors = lifecycleStatusClass;
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppsDict = Dictionary['dashboard']['apps'];
|
type AppsDict = Dictionary['dashboard']['apps'];
|
||||||
|
|
||||||
function statusLabel(status: string, t: Dictionary): string {
|
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 {
|
function lifecycleLabel(lifecycle: string, a: AppsDict): string {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
@@ -402,7 +404,7 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
// Wallet balance for the review step payment
|
// Wallet balance for the review step payment
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet-balance'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
enabled: step >= 2,
|
enabled: step >= 2,
|
||||||
});
|
});
|
||||||
@@ -492,7 +494,7 @@ export default function DeployPage() {
|
|||||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||||
amount: payAmount,
|
amount: payAmount,
|
||||||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||||||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
callbackUrl: localizedCallbackUrl('/dashboard/deploy', locale),
|
||||||
});
|
});
|
||||||
|
|
||||||
// In production, redirect to gw.gatewayUrl
|
// 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 { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
|
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
|
||||||
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
|
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
|
||||||
@@ -44,7 +46,7 @@ export default function InvoicesPage() {
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet-balance'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,8 +73,8 @@ export default function InvoicesPage() {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
|
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyGatewayMutation = useMutation({
|
const verifyGatewayMutation = useMutation({
|
||||||
@@ -99,7 +101,7 @@ export default function InvoicesPage() {
|
|||||||
|
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
mutationFn: async (invoiceId: string) => {
|
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 });
|
const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl });
|
||||||
if (data.gatewayUrl && data.gatewayAmount > 0) {
|
if (data.gatewayUrl && data.gatewayAmount > 0) {
|
||||||
window.location.href = data.gatewayUrl;
|
window.location.href = data.gatewayUrl;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
|
import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
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)
|
// Fetch wallet balance for all authenticated users (shown in header)
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet-balance'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
refetchInterval: 60000,
|
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 { useParams } from 'next/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
@@ -103,7 +104,7 @@ export default function ManagedServiceDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
@@ -134,7 +136,7 @@ export default function NewManagedServicePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet-balance'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
enabled: step === 2,
|
enabled: step === 2,
|
||||||
});
|
});
|
||||||
@@ -236,7 +238,7 @@ export default function NewManagedServicePage() {
|
|||||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||||
amount: payAmount,
|
amount: payAmount,
|
||||||
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
|
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', {
|
await api.post('/billing/gateway/verify', {
|
||||||
trackingCode: gw.trackingCode,
|
trackingCode: gw.trackingCode,
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ import {
|
|||||||
} from '@/components/deleting-overlay';
|
} from '@/components/deleting-overlay';
|
||||||
import { filterManagedServices } from '@/lib/product-type';
|
import { filterManagedServices } from '@/lib/product-type';
|
||||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||||
|
import { lifecycleStatusClass } from '@/lib/app-list-utils';
|
||||||
|
|
||||||
const lifecycleColors: Record<string, string> = {
|
const lifecycleColors = lifecycleStatusClass;
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppsDict = Dictionary['dashboard']['apps'];
|
type AppsDict = Dictionary['dashboard']['apps'];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { localizedCallbackUrl } from '@/lib/locale-url';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import type { WalletTransaction, TransactionType } from '@/types';
|
import type { WalletTransaction, TransactionType } from '@/types';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
@@ -32,12 +34,12 @@ export default function WalletPage() {
|
|||||||
const [showCharge, setShowCharge] = useState(false);
|
const [showCharge, setShowCharge] = useState(false);
|
||||||
|
|
||||||
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet-balance'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
||||||
queryKey: ['wallet-transactions'],
|
queryKey: queryKeys.walletTransactions,
|
||||||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,8 +48,8 @@ export default function WalletPage() {
|
|||||||
mutationFn: (amount: number) =>
|
mutationFn: (amount: number) =>
|
||||||
api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
|
api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||||
notify.success(w.chargedSuccess);
|
notify.success(w.chargedSuccess);
|
||||||
setChargeAmount('');
|
setChargeAmount('');
|
||||||
setShowCharge(false);
|
setShowCharge(false);
|
||||||
@@ -61,7 +63,7 @@ export default function WalletPage() {
|
|||||||
const { data } = await api.post('/billing/gateway/initiate', {
|
const { data } = await api.post('/billing/gateway/initiate', {
|
||||||
amount,
|
amount,
|
||||||
description: w.topUpGatewayDesc,
|
description: w.topUpGatewayDesc,
|
||||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
callbackUrl: localizedCallbackUrl('/dashboard/wallet', locale),
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -72,8 +74,8 @@ export default function WalletPage() {
|
|||||||
trackingCode: data.trackingCode,
|
trackingCode: data.trackingCode,
|
||||||
amount: Number(chargeAmount),
|
amount: Number(chargeAmount),
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions });
|
||||||
notify.success(w.paymentSuccess);
|
notify.success(w.paymentSuccess);
|
||||||
setChargeAmount('');
|
setChargeAmount('');
|
||||||
setShowCharge(false);
|
setShowCharge(false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
@@ -114,7 +115,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
const isDatabase = app.productType === 'managed_database';
|
const isDatabase = app.productType === 'managed_database';
|
||||||
|
|
||||||
const { data: walletData } = useQuery<{ balance: number }>({
|
const { data: walletData } = useQuery<{ balance: number }>({
|
||||||
queryKey: ['wallet'],
|
queryKey: queryKeys.walletBalance,
|
||||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +182,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance });
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
setPendingUpgradePayload(null);
|
setPendingUpgradePayload(null);
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
loadUser();
|
loadUser();
|
||||||
}, [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 (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<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',
|
platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide',
|
||||||
removeCustomDomain: 'Remove Custom Domain',
|
removeCustomDomain: 'Remove Custom Domain',
|
||||||
resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show',
|
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...',
|
noMetrics: 'No metrics yet', storageUsage: 'Storage Usage', loadingStorageMetrics: 'Loading storage metrics...',
|
||||||
noStorageData: 'No storage data available', storageMetricsUnavailable: 'Storage metrics unavailable',
|
noStorageData: 'No storage data available', storageMetricsUnavailable: 'Storage metrics unavailable',
|
||||||
used: 'Used', applyChanges: 'Apply Changes', applying: 'Applying...', processing: 'Processing...',
|
used: 'Used', applyChanges: 'Apply Changes', applying: 'Applying...', processing: 'Processing...',
|
||||||
|
|||||||
@@ -1488,7 +1488,7 @@ const fa = {
|
|||||||
removeCustomDomain: 'حذف دامنهٔ اختصاصی',
|
removeCustomDomain: 'حذف دامنهٔ اختصاصی',
|
||||||
// resources
|
// resources
|
||||||
resourcesScaling: 'منابع و مقیاسبندی', monitor: 'پایش', hide: 'پنهان', show: 'نمایش',
|
resourcesScaling: 'منابع و مقیاسبندی', monitor: 'پایش', hide: 'پنهان', show: 'نمایش',
|
||||||
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریکها…', metrics: 'متریکها', pods: 'پادها', ready: 'آماده',
|
liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریکها…', metrics: 'متریکها', pods: 'پادها', ready: 'آماده', restarts: 'ریاستارت',
|
||||||
noMetrics: 'هنوز متریکی نیست', storageUsage: 'مصرف فضای ذخیره', loadingStorageMetrics: 'در حال بارگذاری متریکهای فضای ذخیره…',
|
noMetrics: 'هنوز متریکی نیست', storageUsage: 'مصرف فضای ذخیره', loadingStorageMetrics: 'در حال بارگذاری متریکهای فضای ذخیره…',
|
||||||
noStorageData: 'دادهی فضای ذخیره موجود نیست', storageMetricsUnavailable: 'متریک فضای ذخیره در دسترس نیست',
|
noStorageData: 'دادهی فضای ذخیره موجود نیست', storageMetricsUnavailable: 'متریک فضای ذخیره در دسترس نیست',
|
||||||
used: 'مصرفشده', applyChanges: 'اعمال تغییرات', applying: 'در حال اعمال…', processing: 'در حال پردازش…',
|
used: 'مصرفشده', applyChanges: 'اعمال تغییرات', applying: 'در حال اعمال…', processing: 'در حال پردازش…',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { loginPath } from '@/lib/locale-url';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
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('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
if (typeof window !== 'undefined') {
|
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);
|
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 {
|
export function formatRemainingDurationMs(remainingMs: number): string {
|
||||||
const ms = Math.max(0, remainingMs);
|
const ms = Math.max(0, remainingMs);
|
||||||
const days = Math.floor(ms / 86400000);
|
const days = Math.floor(ms / 86400000);
|
||||||
@@ -54,7 +57,7 @@ export function formatRemainingForLocale(
|
|||||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||||
if (Number.isNaN(date.getTime())) return '—';
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
const remainingMs = date.getTime() - Date.now();
|
const remainingMs = date.getTime() - Date.now();
|
||||||
return locale === 'fa'
|
return isPersianLocale(locale)
|
||||||
? formatRemainingDurationFa(remainingMs)
|
? formatRemainingDurationFa(remainingMs)
|
||||||
: formatRemainingDurationMs(remainingMs);
|
: formatRemainingDurationMs(remainingMs);
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,7 @@ export function formatExpiresAtLocal(
|
|||||||
): string {
|
): string {
|
||||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||||
if (Number.isNaN(date.getTime())) return '—';
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
if (locale === 'fa') {
|
if (isPersianLocale(locale || '')) {
|
||||||
// Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
|
// Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
|
||||||
// of the runtime's ICU pattern data.
|
// of the runtime's ICU pattern data.
|
||||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
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