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:
keyhan
2026-06-29 20:59:49 +03:30
parent a87bc49393
commit 837f0fa63f
83 changed files with 3953 additions and 1308 deletions
+60
View File
@@ -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
+21
View File
@@ -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.
+2 -1
View File
@@ -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`.
+69
View File
@@ -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
View File
@@ -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‌ها سالم.
+7
View File
@@ -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
+28
View File
@@ -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'
+29 -30
View File
@@ -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
View File
@@ -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"
]
} }
} }
+33
View File
@@ -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}`);
+18
View File
@@ -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', {
+2
View 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);
}
}
+258
View File
@@ -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,
};
}
}
+10 -535
View File
@@ -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);
}
} }
+12 -3
View File
@@ -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');
}
}
+58
View File
@@ -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();
}
}
+2 -1
View File
@@ -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 {}
+55 -456
View File
@@ -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 -2
View File
@@ -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);
} }
/** /**
+93 -93
View File
@@ -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();
}); });
}); });
+5
View File
@@ -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
+25
View File
@@ -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() };
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+2 -2
View File
@@ -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;
}
+4 -2
View File
@@ -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 {}
+49 -95
View File
@@ -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
View File
@@ -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'],
+10
View File
@@ -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 {},
}));
+63
View File
@@ -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);
});
});
+10
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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'),
];
+1 -1
View File
@@ -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.
+1457 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -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;
+2 -1
View File
@@ -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);
+7 -1
View File
@@ -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}>
+24
View File
@@ -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>;
}
+24
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+1 -1
View File
@@ -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...',
+1 -1
View File
@@ -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: 'در حال پردازش…',
+2 -1
View File
@@ -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();
} }
} }
} }
+23
View File
@@ -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;
}
+25
View File
@@ -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');
});
});
+35
View File
@@ -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);
});
});
+6 -3
View File
@@ -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', {
+36
View File
@@ -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');
});
});
+31
View File
@@ -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);
}
+7
View File
@@ -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),
};
+14
View File
@@ -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'),
},
},
});