Compare commits
10 Commits
a58142cc4a
...
5ed2ef0958
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ed2ef0958 | |||
| ee5bd0a291 | |||
| 8d1855b89c | |||
| 837f0fa63f | |||
| a87bc49393 | |||
| 9c16b462f4 | |||
| bd14eb2daa | |||
| f89c3de826 | |||
| 985a23751e | |||
| f7974dd382 |
@@ -0,0 +1,61 @@
|
|||||||
|
name: Build and Deploy Platform
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Push via internal Harbor registry (no creds needed from runner pod)
|
||||||
|
REGISTRY_INTERNAL: harbor-registry.cloudhost.svc.cluster.local:5000
|
||||||
|
REGISTRY: registry.abrban.com
|
||||||
|
BACKEND_IMAGE: abrban/cloudhost-backend
|
||||||
|
FRONTEND_IMAGE: abrban/cloudhost-frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push-deploy:
|
||||||
|
runs-on: abrban-kaniko
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set image tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
echo "IMAGE_TAG=$(date +%Y%m%d-%H%M)-${SHA:0:8}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build backend (Kaniko)
|
||||||
|
run: |
|
||||||
|
/kaniko/executor \
|
||||||
|
--context=./backend \
|
||||||
|
--dockerfile=./backend/Dockerfile \
|
||||||
|
--destination="${REGISTRY_INTERNAL}/${BACKEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
|
--insecure \
|
||||||
|
--skip-tls-verify
|
||||||
|
|
||||||
|
- name: Build frontend (Kaniko)
|
||||||
|
run: |
|
||||||
|
/kaniko/executor \
|
||||||
|
--context=./frontend \
|
||||||
|
--dockerfile=./frontend/Dockerfile \
|
||||||
|
--build-arg=NEXT_PUBLIC_API_URL=https://api.abrban.com \
|
||||||
|
--destination="${REGISTRY_INTERNAL}/${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
|
--insecure \
|
||||||
|
--skip-tls-verify
|
||||||
|
|
||||||
|
- name: Update GitOps values
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sed -i "s|tag: \"[^\"]*\"|tag: \"${IMAGE_TAG}\"|g" gitops/platform/values-abrban.yaml
|
||||||
|
git config user.email "ci@abrban.com"
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git add gitops/platform/values-abrban.yaml
|
||||||
|
git diff --cached --quiet || git commit -m "ci: deploy platform ${IMAGE_TAG}"
|
||||||
|
|
||||||
|
- name: Push GitOps update
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git remote set-url origin "https://oauth2:${GITEA_TOKEN}@git.abrban.com/abrban/cloud-host.git"
|
||||||
|
git push origin HEAD:main
|
||||||
@@ -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
|
||||||
+187
-175
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
CloudHost is a self-service PaaS platform that enables users to deploy **Node.js**, **Laravel**, and **WordPress** applications onto Kubernetes clusters managed by a super admin. It includes a full billing/wallet system, automated lifecycle management, and Helm-based deployments.
|
CloudHost is a self-service PaaS that lets users deploy applications onto Kubernetes
|
||||||
|
clusters managed by a super admin. Source code (uploaded archive or git repo) is turned
|
||||||
|
into a container image **inside the cluster** with Kaniko, then rolled out via Helm. It
|
||||||
|
includes a full billing/wallet system, automated lifecycle management, managed
|
||||||
|
databases/services, Elasticsearch-backed logging, and a bilingual (Persian/English) panel.
|
||||||
|
|
||||||
|
Supported runtimes — each built from a platform-maintained `Dockerfile` template:
|
||||||
|
**Node.js, Laravel, Go, PHP, Python, Django, .NET**, and **WordPress** (official image).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,42 +17,34 @@ CloudHost is a self-service PaaS platform that enables users to deploy **Node.js
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ USERS / ADMINS │
|
│ USERS / ADMINS (Browser) │
|
||||||
│ (Browser / CLI) │
|
|
||||||
└──────────────────────────┬──────────────────────────────────────┘
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
│ HTTPS
|
│ HTTPS
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ FRONTEND (Next.js 14) │
|
│ FRONTEND — Next.js 16 (App Router, bilingual) │
|
||||||
│ ┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐ │
|
│ Landing │ Auth (OTP) │ Deploy Wizard │ Dashboard │ Admin Panel │
|
||||||
│ │ Auth UI │ │ Deploy Wizard │ │ Dashboard│ │Admin Panel│ │
|
|
||||||
│ └──────────┘ └───────────────┘ └──────────┘ └───────────┘ │
|
|
||||||
└──────────────────────────┬──────────────────────────────────────┘
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
│ REST API (JSON)
|
│ REST /api/v1 (JSON)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ BACKEND (NestJS 10) │
|
│ BACKEND — NestJS 11 │
|
||||||
│ │
|
│ Auth · Users · Admin · Applications · Deployments · Clusters │
|
||||||
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ │
|
│ Build · Billing · Lifecycle · Snapshots · Tickets · Access · │
|
||||||
│ │Auth │ │Applications │ │Deployments │ │Clusters │ │
|
│ Notifications · Kubernetes/Helm/Registry │
|
||||||
│ │Module │ │Module │ │Module │ │Module │ │
|
└───┬───────────┬───────────────────┬───────────────┬─────────────┘
|
||||||
│ └──────────┘ └──────────────┘ └────────────┘ └──────────┘ │
|
│ │ │ │
|
||||||
│ │
|
▼ ▼ ▼ ▼
|
||||||
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ │
|
┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────────────┐
|
||||||
│ │Billing │ │Lifecycle │ │Snapshots │ │Tickets │ │
|
│Postgres│ │ Redis │ │ Registry │ │ Kubernetes │
|
||||||
│ │Module │ │Module │ │Module │ │Module │ │
|
│ 16 │ │(cache/ │ │ (:2) │ │ Cluster(s) │
|
||||||
│ └──────────┘ └──────────────┘ └────────────┘ └──────────┘ │
|
│ │ │ Bull) │ └──────────┘ │ ┌────────────┐ │
|
||||||
│ │
|
└────────┘ └────────┘ │ │ Build Jobs │ │
|
||||||
│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ │ (Kaniko) │ │
|
||||||
│ │ Kubernetes │ │ Helm │ │ Build │ │
|
│ └────────────┘ │
|
||||||
│ │ Service │ │ Service │ │ Service │ │
|
│ Helm releases │
|
||||||
│ └────────┬─────────┘ └──────┬───────┘ └──────┬───────────┘ │
|
│ (user apps) │
|
||||||
└───────────┼───────────────────┼──────────────────┼───────────────┘
|
└──────────────────┘
|
||||||
│ │ │
|
|
||||||
┌───────▼────────┐ ┌──────▼────────┐ ┌──────▼────────┐
|
|
||||||
│ Kubernetes │ │ Helm CLI │ │ Kaniko │
|
|
||||||
│ Cluster(s) │ │ (v3) │ │ (in-cluster) │
|
|
||||||
└───────────────┘ └───────────────┘ └───────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -54,110 +53,118 @@ CloudHost is a self-service PaaS platform that enables users to deploy **Node.js
|
|||||||
|
|
||||||
| Module | Purpose |
|
| Module | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| **Auth** | JWT access/refresh tokens, Passport strategies, role guards |
|
| **Auth** | Mobile-number + OTP (SMS) and password login; JWT access/refresh; Passport strategies; role guards |
|
||||||
| **Users** | User CRUD, admin activate/deactivate, profile management |
|
| **Users** | User CRUD, profile, phone verification |
|
||||||
| **Applications** | App CRUD, code upload (zip), metadata, runtime detection |
|
| **Admin** | Super-admin user-detail dashboard and operations |
|
||||||
| **Build** | Kaniko-based image builds via BullMQ queue; auto-detects Node.js/Laravel/WordPress |
|
| **Applications** | App CRUD, code upload (→ disk), git config, runtime/version metadata |
|
||||||
| **Kubernetes** | K8s API interactions — namespace, scale, delete, pod logs, build pods |
|
| **Application-migrations** | Import / migrate existing applications (Bull queue) |
|
||||||
| **Helm** | Helm CLI wrapper — install/upgrade, rollback, uninstall, history |
|
| **Build** | `build.service` — runtime detection + per-runtime Dockerfile generation, Kaniko image builds inside K8s |
|
||||||
| **Deployments** | Deployment lifecycle orchestration, history, stop/restart |
|
| **Deployments** | Deploy orchestration (build → Helm), history, stop/restart |
|
||||||
| **Clusters** | Multi-cluster management, kubeconfig storage, default cluster selection |
|
| **Kubernetes** | K8s API wrapper, Helm CLI wrapper, registry service |
|
||||||
| **Billing** | Wallet system (deposit/deduct), transaction ledger, plan cost calculation |
|
| **Clusters** | Multi-cluster management, kubeconfig storage, default cluster |
|
||||||
| **Lifecycle** | Cron-based scanner: auto-suspend expired apps, auto-delete after grace period |
|
| **Billing** | Wallet (deposit/deduct), transaction ledger, invoices, pricing catalog, coupons/discounts |
|
||||||
| **Snapshots** | Application snapshot/backup management |
|
| **Lifecycle** | Interval scanner: auto-suspend expired apps, auto-delete after grace period |
|
||||||
| **Tickets** | Support ticket system for users |
|
| **Snapshots** | Application snapshot/backup & restore |
|
||||||
|
| **Tickets** | Support ticket system (technical/sales departments) |
|
||||||
|
| **Access** | Time-limited external access to app services via temporary NodePort grants (Bull queue) |
|
||||||
|
| **Notifications** | User-facing notifications |
|
||||||
|
| **Common / Config** | Shared enums, guards, decorators; env & TypeORM config |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Tech Stack
|
## 🔧 Tech Stack
|
||||||
|
|
||||||
### Frontend: Next.js 14 (App Router) + Tailwind CSS
|
### Frontend: Next.js 16 (App Router) + Tailwind CSS v4
|
||||||
|
|
||||||
| Reason | Detail |
|
| Reason | Detail |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **SSR & SEO** | Server-side rendering for fast initial loads |
|
| **SSR & SEO** | Server-side rendering for fast initial loads and a public landing/blog |
|
||||||
| **App Router** | React Server Components, layouts, loading states |
|
| **App Router** | React Server Components, layouts, locale routing under `app/[lang]/` |
|
||||||
| **Tailwind CSS** | Rapid UI development, consistent design system |
|
| **Bilingual** | `fa-IR` (default) + `en-US`; `middleware.ts` also splits landing vs authenticated panel |
|
||||||
| **TypeScript** | End-to-end type safety with shared types |
|
| **React Query** | Server state, caching, polling for live build/deploy status |
|
||||||
| **React Query** | Server state management, caching, polling for live status |
|
| **Zustand** | Lightweight client auth store |
|
||||||
| **Zustand** | Lightweight client state management (auth store) |
|
| **TypeScript** | End-to-end type safety |
|
||||||
|
|
||||||
### Backend: NestJS 10 (Node.js)
|
### Backend: NestJS 11 (Node.js 20)
|
||||||
|
|
||||||
| Reason | Detail |
|
| Reason | Detail |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Modular architecture** | Each domain is a self-contained module |
|
| **Modular** | Each domain is a self-contained module |
|
||||||
| **TypeScript native** | Full type safety, shared interfaces with frontend |
|
| **@kubernetes/client-node** | Direct K8s API interaction (Jobs, Deployments, logs, scale) |
|
||||||
| **@kubernetes/client-node** | Official K8s client for direct API interaction |
|
| **Helm CLI** | Shell-out to helm for chart-based app deployments |
|
||||||
| **Helm CLI** | Shell-out to helm for chart-based deployments |
|
| **Bull (Redis)** | Async queues for service-access grants and application migrations |
|
||||||
| **Bull/BullMQ** | Redis-backed job queues for async build pipelines |
|
| **TypeORM** | PostgreSQL ORM. `synchronize` is **development-only**; production schema changes ship as idempotent SQL migrations / `ALTER ... IF NOT EXISTS` |
|
||||||
| **TypeORM** | PostgreSQL ORM with entity-based schema |
|
|
||||||
|
|
||||||
### Build System: Kaniko (in-cluster)
|
### Build System: Kaniko (in-cluster)
|
||||||
|
|
||||||
| Reason | Detail |
|
| Reason | Detail |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **No Docker daemon** | Builds inside K8s pods — no Docker-in-Docker |
|
| **No Docker daemon** | Builds run as unprivileged K8s Jobs in `cloudhost-builds` |
|
||||||
| **Runtime detection** | Auto-detects Node.js, Laravel, WordPress from source files |
|
| **Runtime detection** | `detectRuntime()` infers Node.js / Laravel / WordPress from source files; the app may also pin a runtime explicitly |
|
||||||
| **WordPress support** | Custom entrypoint script for wp-content merging |
|
| **Per-runtime Dockerfiles** | `generateDockerfile()` emits a tailored Dockerfile for Node.js, Laravel, WordPress, Go, PHP, Python, Django, or .NET |
|
||||||
| **Registry push** | Native push to insecure or authenticated registries |
|
| **WordPress** | Templated Dockerfile + custom entrypoint that merges `wp-content` |
|
||||||
|
| **Source ingestion** | Uploaded archives saved to disk and streamed into a per-build PVC (helper pod + `kubectl cp`); git repos cloned in-pod |
|
||||||
|
| **Registry push** | Native push to the in-cluster (insecure) registry |
|
||||||
|
|
||||||
### Deployment: Helm v3 Charts
|
### Deployment: Helm v3 Charts
|
||||||
|
|
||||||
| Reason | Detail |
|
| Reason | Detail |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Templated manifests** | Single chart handles Node.js, Laravel, WordPress |
|
| **Templated manifests** | One `cloudhost-app` chart handles all runtimes + attached services |
|
||||||
| **Rollback support** | Built-in revision history and rollback |
|
| **Rollback** | Built-in revision history |
|
||||||
| **Resource policies** | PVCs and secrets persist across helm uninstall |
|
| **Persistence** | DB/app PVCs and secrets use keep policies so they survive helm uninstall |
|
||||||
| **Registry pull secrets** | Auto-created per namespace for insecure registries |
|
| **Ingress** | Traefik by default (k3s); `INGRESS_CLASS=nginx` for ingress-nginx |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Build & Deploy Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User triggers deploy (panel)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend runs the build-and-deploy pipeline, creating a Kubernetes Job in `cloudhost-builds`:
|
||||||
|
|
||||||
|
┌─ init: prepare source (uploaded zip → disk → helper pod + `kubectl cp` → build PVC) ─┐
|
||||||
|
│ …or… │ → /workspace/source
|
||||||
|
└─ init: git-clone (clone repo; token injected into the URL for private repos) ───┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
The platform detects the runtime and generates a Dockerfile for it
|
||||||
|
(Node.js / Laravel / WordPress / Go / PHP / Python / Django / .NET)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
container: kaniko → build image (cache per user) → push to in-cluster registry
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
HelmService install/upgrade `cloudhost-app`
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Helm creates: Namespace, Deployment, Service, Ingress (+TLS), per-app
|
||||||
|
DB/Redis/RabbitMQ, PVCs, Secrets, registry pull secret, log shipper
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
App live at https://<subdomain>.<PLATFORM_DOMAIN>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The build runs **inline** within the deploy request (it is not queued); build
|
||||||
|
progress/logs are tracked in memory and polled by the frontend. This assumes a single
|
||||||
|
active backend replica for an in-flight build.
|
||||||
|
- Bull/Redis queues are used by other subsystems (service-access grants, application
|
||||||
|
migrations), not by the image build.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔐 Security Architecture
|
## 🔐 Security Architecture
|
||||||
|
|
||||||
- JWT Authentication (access + refresh tokens)
|
- Mobile-OTP + password authentication; JWT access + refresh
|
||||||
- Role-Based Access Control (User / Admin)
|
- **Live** role/active-status enforcement — `JwtStrategy` reads the user from the DB each request
|
||||||
- K8s Namespace Isolation per user
|
- Role-Based Access Control (`user` / `admin` / `technical` / `sales`)
|
||||||
- K8s RBAC — scoped ServiceAccounts
|
- K8s namespace isolation per user; scoped ServiceAccounts
|
||||||
- Network Policies between namespaces
|
- Resource quotas & limit ranges; expandable per-app storage
|
||||||
- Resource Quotas & Limit Ranges
|
- Secrets stored as K8s Secrets (env vars, DB creds)
|
||||||
- Secrets encryption (K8s Secrets)
|
- Input validation (class-validator), Helmet headers, Bcrypt password hashing
|
||||||
- Input validation (class-validator on all DTOs)
|
|
||||||
- Helmet HTTP security headers
|
|
||||||
- Bcrypt password hashing (12 rounds)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Deployment Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User uploads code (zip)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
API stores file + metadata in PostgreSQL
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
BullMQ build job queued
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
detectRuntime() → nodejs | laravel | wordpress
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Generate Dockerfile per runtime
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Kaniko Pod builds image → pushes to registry
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
HelmService.installOrUpgrade() with cloudhost-app chart
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Helm creates: Namespace, Deployment, Service, Ingress,
|
|
||||||
DB, PVC, Secrets, Registry Pull Secret, TLS cert
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
App live at https://<subdomain>.apps.cloudhost.ir
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -168,95 +175,100 @@ ACTIVE ──(expires)──► SUSPENDED ──(grace)──► PENDING_DELETIO
|
|||||||
▲ │ │
|
▲ │ │
|
||||||
└────── payment ────────┘ │
|
└────── payment ────────┘ │
|
||||||
└────── payment (within grace) ──────────────────┘
|
└────── payment (within grace) ──────────────────┘
|
||||||
|
|
||||||
|
DOCKED ── user removed the service; data retained until the plan expires
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Billing Cycles**: HOURLY | MONTHLY | YEARLY
|
- **Billing cycles**: HOURLY | MONTHLY | YEARLY
|
||||||
- **Hourly plans**: auto-renew from wallet each hour
|
- **Wallet**: deposits, deductions, refunds, gateway payments; invoices with coupons/discounts
|
||||||
- **Grace periods**: admin-configurable via PlatformSettings table
|
- **Grace periods**: admin-configurable via the PlatformSettings entity (per cycle)
|
||||||
- **Lifecycle Scanner**: runs every 60s (configurable)
|
- **Lifecycle scanner**: runs on an interval (default 60s); suspends expired apps (scale to 0,
|
||||||
|
data retained) and deletes them after the grace period
|
||||||
|
|
||||||
|
> ⚠️ The lifecycle scanner and other interval jobs assume a **single backend replica** —
|
||||||
|
> guard them (e.g. a Redis lock) before scaling the control plane horizontally.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Helm Chart: cloudhost-platform
|
## 📦 Helm Charts
|
||||||
|
|
||||||
Chart at `backend/helm/cloudhost-platform/` deploys the **control plane** (NestJS API, Next.js UI, PostgreSQL, Redis) into a dedicated namespace (default `cloudhost`).
|
### cloudhost-platform — control plane
|
||||||
|
|
||||||
| Value | Purpose |
|
Deploys the API, UI, PostgreSQL, and Redis into a namespace (default `cloudhost`).
|
||||||
|-------|---------|
|
|
||||||
| `ingress.enabled` | Create Ingress (default `true`) |
|
|
||||||
| `ingress.tls.enabled` | cert-manager TLS via `clusterIssuer` |
|
|
||||||
| `ingress.frontend.host` / `ingress.api.host` | Public hostnames |
|
|
||||||
| `postgres.password` / `secrets.jwtSecret` | Credentials (auto-generated if empty on first install) |
|
|
||||||
| `migrations.enabled` | Post-install SQL migration Job |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Helm Chart: cloudhost-app
|
|
||||||
|
|
||||||
Single chart at `backend/helm/cloudhost-app/` handles all runtimes:
|
|
||||||
|
|
||||||
| Template | Purpose |
|
| Template | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `deployment.yaml` | App pod with imagePullSecrets, probes, WordPress volumes |
|
| `backend-deployment.yaml` / `backend-service.yaml` / `backend-pvc.yaml` | NestJS API + uploads PVC |
|
||||||
| `service.yaml` | ClusterIP (port 80 → app port) |
|
| `frontend-deployment.yaml` / `frontend-service.yaml` | Next.js UI |
|
||||||
| `ingress.yaml` | Nginx ingress with cert-manager TLS |
|
| `postgres-*.yaml` / `redis-*.yaml` | Control-plane database & queue |
|
||||||
| `secret.yaml` | User env vars as K8s Secret |
|
| `ingress.yaml` | Frontend / API / panel host rules (+ TLS) |
|
||||||
| `db-deployment.yaml` | PostgreSQL or MySQL with health probes |
|
| `secret.yaml` | JWT, DB, registry, SMS and other platform secrets |
|
||||||
| `db-service.yaml` | Database ClusterIP service |
|
| `migrations-configmap.yaml` / `migrations-job.yaml` | Optional SQL migration Job (`migrations.enabled`) |
|
||||||
| `db-pvc.yaml` | Database storage (resource-policy: keep) |
|
| `namespace.yaml` / `_helpers.tpl` / `NOTES.txt` | Namespace + chart helpers |
|
||||||
| `db-secret.yaml` | Database credentials (resource-policy: keep) |
|
|
||||||
| `wp-pvc.yaml` | WordPress wp-content PVC (resource-policy: keep) |
|
Key values: `ingress.enabled`, `ingress.tls.*`, `ingress.frontend.host` / `ingress.api.host`,
|
||||||
| `registry-pull-secret.yaml` | imagePullSecret for insecure registry |
|
`postgres.password`, `secrets.jwtSecret`, `migrations.enabled`.
|
||||||
|
|
||||||
|
### cloudhost-app — a single user application
|
||||||
|
|
||||||
|
| Template | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `deployment.yaml` | App pod (imagePullSecret, probes, WordPress volumes, env) |
|
||||||
|
| `service.yaml` / `ingress.yaml` | ClusterIP + ingress with TLS |
|
||||||
|
| `secret.yaml` | User env vars as a K8s Secret |
|
||||||
|
| `db-deployment.yaml` / `db-service.yaml` / `db-pvc.yaml` / `db-secret.yaml` | Optional managed PostgreSQL/MySQL/MariaDB/MongoDB |
|
||||||
|
| `redis-deployment.yaml` / `rabbitmq-deployment.yaml` | Optional attached services |
|
||||||
|
| `app-storage-pvc.yaml` | App persistent storage |
|
||||||
|
| `storageclass.yaml` | Expandable StorageClass (created on demand) |
|
||||||
|
| `registry-pull-secret.yaml` | imagePullSecret for the in-cluster registry |
|
||||||
|
| `fluent-bit-configmap.yaml` / `log-shipper-configmap.yaml` / `_log-shipper.tpl` / `elasticsearch-credentials-secret.yaml` | Per-app log shipping to Elasticsearch |
|
||||||
|
|
||||||
|
### cloudhost-logging — observability
|
||||||
|
|
||||||
|
Elasticsearch / Kibana / Fluent-bit stack for centralized build and runtime logs
|
||||||
|
(also see `backend/k8s/logging/`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
host/
|
cloud-host/
|
||||||
├── ARCHITECTURE.md
|
├── ARCHITECTURE.md README.md RUNBOOK.fa.md CHANGELOG.md CONTRIBUTING.md
|
||||||
├── README.md
|
├── UPGRADE.md UPGRADE.en.md docker-compose.yml
|
||||||
├── CHANGELOG.md
|
├── backend/ # NestJS 11 API
|
||||||
├── CONTRIBUTING.md
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── backend/
|
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── package.json
|
│ ├── helm/{cloudhost-platform, cloudhost-app, cloudhost-logging}/
|
||||||
│ ├── helm/cloudhost-platform/ # Helm chart for control plane
|
│ ├── k8s/{logging, mail}/ # standalone manifests
|
||||||
│ ├── helm/cloudhost-app/ # Helm chart for user apps
|
│ ├── migrations/ # SQL migrations (one-off Jobs in prod)
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── main.ts / app.module.ts
|
|
||||||
│ │ ├── auth/ # JWT + Passport
|
|
||||||
│ │ ├── users/ # User management
|
|
||||||
│ │ ├── applications/ # App CRUD + upload
|
|
||||||
│ │ ├── deployments/ # Deploy orchestration
|
|
||||||
│ │ ├── clusters/ # Multi-cluster (admin)
|
|
||||||
│ │ ├── kubernetes/ # K8s client + Helm service
|
|
||||||
│ │ ├── build/ # Kaniko builds (BullMQ)
|
|
||||||
│ │ ├── billing/ # Wallet + transactions
|
|
||||||
│ │ ├── lifecycle/ # Auto-suspend/delete
|
|
||||||
│ │ ├── snapshots/ # App snapshots
|
|
||||||
│ │ └── tickets/ # Support tickets
|
|
||||||
│ └── templates/ # Legacy Handlebars (deprecated)
|
|
||||||
├── frontend/
|
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── app/dashboard/ # Apps, deploy, admin pages
|
│ ├── main.ts / app.module.ts
|
||||||
│ ├── components/
|
│ ├── auth/ users/ admin/ # OTP auth, users, super-admin dashboard
|
||||||
│ ├── lib/ # API client, auth store
|
│ ├── applications/ application-migrations/
|
||||||
│ └── types/ # Shared TS interfaces
|
│ ├── deployments/ # orchestration (build → Helm)
|
||||||
└── uploads/ # User-uploaded code archives
|
│ ├── build/ # build.service: runtime detection + per-runtime Dockerfiles + Kaniko
|
||||||
|
│ ├── kubernetes/ # K8s client, Helm, registry
|
||||||
|
│ ├── clusters/ # multi-cluster management
|
||||||
|
│ ├── billing/ lifecycle/ snapshots/ tickets/ access/ notifications/
|
||||||
|
│ ├── common/ # enums, guards, decorators
|
||||||
|
│ └── config/ # env + TypeORM config
|
||||||
|
└── frontend/ # Next.js 16 (App Router, fa-IR / en-US)
|
||||||
|
├── Dockerfile # ARG NEXT_PUBLIC_API_URL
|
||||||
|
└── src/
|
||||||
|
├── middleware.ts # locale routing + landing/panel split
|
||||||
|
├── app/[lang]/{page, login, register, blog, dashboard/*}
|
||||||
|
├── components/ hooks/ lib/ types/
|
||||||
|
└── i18n/ # dictionaries, provider, switcher
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔮 Future Considerations
|
## 🔮 Future Considerations
|
||||||
|
|
||||||
1. Custom domains with auto TLS via cert-manager
|
1. Per-app horizontal autoscaling (HPA) based on CPU/memory
|
||||||
2. Horizontal Pod Autoscaler based on CPU/memory
|
2. WebSocket/SSE for real-time build log streaming (currently polled)
|
||||||
3. WebSocket/SSE for real-time build log streaming
|
3. A Redis-backed build queue (so builds survive a replica restart and the control plane can scale out)
|
||||||
4. GitOps integration (ArgoCD)
|
4. In-cluster image vulnerability scanning (report-only)
|
||||||
5. Additional runtimes (Python, Go, Rust)
|
5. GitOps integration (e.g. ArgoCD) and git-push-to-deploy
|
||||||
6. App marketplace with pre-built templates
|
6. Automated control-plane database backups (scheduled `pg_dump` + retention)
|
||||||
7. Per-app resource consumption dashboards
|
7. App marketplace with pre-built templates
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,303 +1,318 @@
|
|||||||
# ☁️ CloudHost — Self-Service PaaS Platform
|
# ☁️ CloudHost — Self-Service PaaS Platform
|
||||||
|
|
||||||
A self-service Platform-as-a-Service (PaaS) that lets developers deploy **Node.js**, **Laravel**, and **WordPress** applications onto Kubernetes with zero DevOps overhead. Includes wallet-based billing, automated lifecycle management, and Helm-based deployments.
|
A self-service Platform-as-a-Service (PaaS) that lets developers deploy applications
|
||||||
|
onto Kubernetes with zero DevOps overhead. Source code is turned into a container
|
||||||
|
image **inside the cluster** with Kaniko (no Docker daemon), then rolled out with Helm —
|
||||||
|
complete with managed databases, wallet-based billing, automated lifecycle management,
|
||||||
|
live logs, and a bilingual (Persian/English) panel.
|
||||||
|
|
||||||
|
Supported runtimes — each built from a platform-maintained `Dockerfile` template:
|
||||||
|
**Node.js, Laravel, Go, PHP, Python, Django, .NET**, and **WordPress** (official image +
|
||||||
|
custom `wp-content` entrypoint).
|
||||||
|
|
||||||
|
> 🇮🇷 Production deployment on the `abrban.com` k3s cluster — including all the
|
||||||
|
> Iran-network workarounds — is documented step-by-step in **[RUNBOOK.fa.md](RUNBOOK.fa.md)** (Persian).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
|
┌──────────────┐ REST ┌──────────────────┐ K8s API ┌──────────────┐
|
||||||
│ Next.js 16 │ REST │ NestJS API │ K8s │ Kubernetes │
|
│ Next.js 16 │ /api/v1 │ NestJS 11 API │ + Helm │ Kubernetes │
|
||||||
│ Frontend │◄───────►│ Backend │◄──────►│ Cluster(s) │
|
│ Frontend │◄─────────►│ Backend │◄───────────►│ Cluster(s) │
|
||||||
└─────────────┘ └────────┬────────┘ └──────────────┘
|
└──────────────┘ └────────┬─────────┘ └──────┬───────┘
|
||||||
│
|
│ │ build Jobs
|
||||||
┌──────────┼──────────┐
|
┌─────────────────┼─────────────────┐ ▼
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼ ┌──────────┐
|
||||||
PostgreSQL Redis Container
|
PostgreSQL Redis Registry │ Kaniko │
|
||||||
(Bull) Registry
|
16 (cache + Bull) (:2) └──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
| ------------ | ------------------------------------------------------- |
|
| ---------------- | ----------------------------------------------------------------- |
|
||||||
| Frontend | Next.js 16, Tailwind CSS v4, React Query, Zustand |
|
| Frontend | Next.js 16 (App Router, SSR), React 19, Tailwind CSS v4, React Query, Zustand |
|
||||||
| Backend API | NestJS 11, TypeORM, Passport JWT, Bull (Redis) |
|
| Backend API | NestJS 11, TypeORM, Passport JWT, Bull (Redis) |
|
||||||
| Build Engine | Kaniko (in-cluster, daemon-less Docker builds) |
|
| Build engine | **Kaniko** (daemon-less in-cluster builds) with platform-generated per-runtime Dockerfiles |
|
||||||
| Deployment | Helm v3 charts, @kubernetes/client-node |
|
| Source ingestion | Uploaded archive (zip/tar.gz) streamed into a build PVC, **or** git clone |
|
||||||
| Database | PostgreSQL 16 |
|
| Deployment | Helm v3 charts, `@kubernetes/client-node` |
|
||||||
| Queue | Redis 7 + BullMQ |
|
| Database | PostgreSQL 16 (control plane); per-app MySQL/MariaDB/PostgreSQL/MongoDB |
|
||||||
|
| Queue / cache | Redis 7 + Bull (service-access grants, app migrations) |
|
||||||
|
| Registry | In-cluster `registry:2` |
|
||||||
|
| Auth | Mobile number + **OTP** (SMS) and password, JWT access/refresh |
|
||||||
|
|
||||||
> 📖 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 the latest dependency-upgrade notes (React 19, Next 16, NestJS 11, Tailwind 4, k8s-client v1).
|
> 🔼 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### For Developers
|
### For Developers
|
||||||
- 🚀 **One-click deploys** from uploaded code archive (zip)
|
- 🚀 **Deploy from a code archive (zip/tar.gz) _or_ a git URL** (public or private via token)
|
||||||
- 🟢 **Node.js** — auto-detected via `package.json` (npm build & start)
|
- 🟢 **Multi-runtime** — Node.js, Laravel, Go, PHP, Python, Django, .NET, each built from a maintained Dockerfile template
|
||||||
- 🟣 **Laravel** — PHP 8.x + Nginx + Supervisor (auto-detected via `artisan`)
|
- 🔵 **WordPress** — official image + custom entrypoint that merges your `wp-content`
|
||||||
- 🔵 **WordPress** — official image + custom entrypoint for wp-content merging
|
- 🗄️ **Managed databases & services** — PostgreSQL, MySQL, MariaDB, MongoDB, Redis, RabbitMQ provisioned via Helm
|
||||||
- 🗄️ **Managed databases** — PostgreSQL or MySQL provisioned via Helm
|
- 💰 **Wallet system** — deposit funds, pay per plan (hourly / monthly / yearly), coupons & discounts
|
||||||
- 💰 **Wallet system** — deposit funds, pay for plans (hourly/monthly/yearly)
|
- 📊 **Live build & runtime logs** (Elasticsearch-backed) + deployment history with rollback
|
||||||
- 📊 **Live logs** & deployment history with rollback
|
- 🔒 **Environment variables** stored as Kubernetes Secrets
|
||||||
- 🔒 **Environment variables** managed as Kubernetes Secrets
|
- ⚙️ **Resource controls** — CPU, memory, replicas, expandable disk
|
||||||
- ⚙️ **Resource controls** — CPU, memory, replica count
|
- 🌐 **Custom domains** with automatic TLS
|
||||||
- 📸 **Snapshots** — backup and restore application state
|
- 📸 **Snapshots** — backup & restore application state
|
||||||
- 🎫 **Support tickets** — in-app support system
|
- 🎫 **Support tickets** with technical/sales departments
|
||||||
|
|
||||||
### For Super Admins
|
### For Super Admins
|
||||||
- 🖥️ **Multi-cluster management** — register/remove Kubernetes clusters
|
- 🖥️ **Multi-cluster management** — register/remove Kubernetes clusters (kubeconfig stored encrypted)
|
||||||
- 👥 **User management** — activate, deactivate, change roles
|
- 👥 **User management** — activate, deactivate, change roles, per-user detail dashboard
|
||||||
- 📈 **Quotas** — per-cluster limits (CPU, memory, max apps)
|
- 📈 **Quotas & pricing** — per-cluster limits and a configurable pricing catalog
|
||||||
- 💳 **Billing oversight** — view all transactions, manage wallet deposits
|
- 💳 **Billing oversight** — transactions, invoices, wallet deposits, global discount
|
||||||
- ⏱️ **Lifecycle settings** — configure grace periods per billing cycle
|
- ⏱️ **Lifecycle settings** — grace periods per billing cycle
|
||||||
- 🔐 **RBAC** — role-based guards on every endpoint
|
- 🔐 **RBAC** — role-based guards on every endpoint (`user` / `admin` / `technical` / `sales`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Build & Deploy Pipeline Works
|
||||||
|
|
||||||
|
When a user triggers a deploy, the backend runs the build-and-deploy pipeline and creates
|
||||||
|
the build as a **Kubernetes Job** in the `cloudhost-builds` namespace:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. SOURCE
|
||||||
|
├─ uploaded archive → saved to disk (UPLOAD_DIR) → streamed into a per-build PVC
|
||||||
|
│ via a short-lived helper pod + `kubectl cp`, then unpacked (init: prepare source)
|
||||||
|
└─ git URL → cloned in-pod; private repos inject the token into the clone URL (init: git-clone)
|
||||||
|
|
||||||
|
2. DOCKERFILE
|
||||||
|
The platform detects the runtime (or uses the app's selected runtime) and generates a
|
||||||
|
Dockerfile for it — Node.js, Laravel, WordPress, Go, PHP, Python, Django, or .NET.
|
||||||
|
|
||||||
|
3. BUILD (container: kaniko)
|
||||||
|
Kaniko builds the image (layer cache per user) and pushes it to the in-cluster
|
||||||
|
registry — no Docker daemon, no privileged pod.
|
||||||
|
|
||||||
|
4. DEPLOY
|
||||||
|
Helm installs/upgrades the `cloudhost-app` chart → Deployment, Service, Ingress,
|
||||||
|
per-app DB/Redis/RabbitMQ, PVCs, Secrets, log shipper. App goes live at its subdomain.
|
||||||
|
```
|
||||||
|
|
||||||
|
The build runs inline within the deploy request and its progress/logs are tracked in
|
||||||
|
memory, then polled by the frontend. (Bull/Redis queues are used elsewhere — service-access
|
||||||
|
grants and application migrations — but not for image builds.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
host/
|
cloud-host/
|
||||||
├── ARCHITECTURE.md # Detailed architecture document
|
├── README.md # This file
|
||||||
├── README.md # This file
|
├── ARCHITECTURE.md # Detailed system design
|
||||||
├── CHANGELOG.md # Version history
|
├── RUNBOOK.fa.md # Persian runbook: local dev + abrban/k3s production deploy
|
||||||
├── CONTRIBUTING.md # Development workflow & conventions
|
├── CHANGELOG.md / CONTRIBUTING.md / UPGRADE.md / UPGRADE.en.md
|
||||||
├── docker-compose.yml # Local dev / production compose
|
├── docker-compose.yml # Local dev stack (Postgres + Redis + API + UI)
|
||||||
│
|
│
|
||||||
├── backend/ # NestJS API
|
├── backend/ # NestJS 11 API (REST under /api/v1)
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── package.json
|
|
||||||
│ ├── helm/
|
│ ├── helm/
|
||||||
│ │ ├── cloudhost-platform/ # Helm chart (control plane)
|
│ │ ├── cloudhost-platform/ # Helm chart — control plane (API, UI, Postgres, Redis)
|
||||||
│ │ └── cloudhost-app/ # Helm chart (user apps)
|
│ │ ├── cloudhost-app/ # Helm chart — a single user application + its services
|
||||||
│ │ ├── Chart.yaml
|
│ │ └── cloudhost-logging/ # Helm chart — Elasticsearch / Kibana / Fluent-bit
|
||||||
│ │ ├── values.yaml
|
│ ├── k8s/ # Standalone manifests (logging, mail)
|
||||||
│ │ └── templates/ # K8s manifest templates
|
│ ├── migrations/ # SQL migrations (applied via one-off Jobs in prod)
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── main.ts / app.module.ts
|
|
||||||
│ │ ├── auth/ # JWT auth (register, login, refresh)
|
|
||||||
│ │ ├── users/ # User CRUD + admin ops
|
|
||||||
│ │ ├── applications/ # Application CRUD + code upload
|
|
||||||
│ │ ├── deployments/ # Deployment pipeline orchestration
|
|
||||||
│ │ ├── clusters/ # Cluster management (admin)
|
|
||||||
│ │ ├── kubernetes/ # K8s client + Helm service
|
|
||||||
│ │ ├── build/ # Kaniko build jobs (Bull queue)
|
|
||||||
│ │ ├── billing/ # Wallet, transactions, plan costs
|
|
||||||
│ │ ├── lifecycle/ # Auto-suspend/delete scanner
|
|
||||||
│ │ ├── snapshots/ # App snapshot management
|
|
||||||
│ │ ├── tickets/ # Support ticket system
|
|
||||||
│ │ ├── common/ # Enums, decorators, guards
|
|
||||||
│ │ └── config/ # Env configuration loader
|
|
||||||
│ └── templates/ # Legacy Handlebars templates (deprecated)
|
|
||||||
│
|
|
||||||
├── frontend/ # Next.js 14 App Router
|
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── app/
|
│ ├── main.ts / app.module.ts
|
||||||
│ │ ├── login/ & register/
|
│ ├── auth/ # Mobile-OTP + password login, JWT strategies, role guards
|
||||||
│ │ └── dashboard/
|
│ ├── users/ # User CRUD, profile, phone verification
|
||||||
│ │ ├── apps/ # App list + detail (lifecycle status)
|
│ ├── admin/ # Super-admin user-detail dashboard & ops
|
||||||
│ │ ├── deploy/ # Multi-step deploy wizard
|
│ ├── applications/ # App CRUD, code upload (→ disk), git config
|
||||||
│ │ └── admin/ # Admin: users, clusters, billing, apps
|
│ ├── application-migrations/ # Import/migrate existing apps (Bull queue)
|
||||||
│ ├── components/
|
│ ├── deployments/ # Deploy orchestration, history, stop/restart
|
||||||
│ ├── lib/ # API client, auth store
|
│ ├── build/ # Kaniko build (build.service): per-runtime Dockerfile generation
|
||||||
│ ├── hooks/
|
│ ├── kubernetes/ # K8s client, Helm wrapper, registry service
|
||||||
│ └── types/ # TypeScript interfaces
|
│ ├── clusters/ # Multi-cluster management, kubeconfig storage
|
||||||
|
│ ├── billing/ # Wallet, transactions, invoices, pricing catalog, coupons
|
||||||
|
│ ├── lifecycle/ # Scanner: auto-suspend/delete expired apps
|
||||||
|
│ ├── snapshots/ # App snapshot/restore
|
||||||
|
│ ├── tickets/ # Support tickets
|
||||||
|
│ ├── notifications/ # User notifications
|
||||||
|
│ ├── access/ # Time-limited external service access (NodePort grants, Bull queue)
|
||||||
|
│ ├── common/ # Enums, guards, decorators
|
||||||
|
│ └── config/ # Env configuration loader + TypeORM config
|
||||||
│
|
│
|
||||||
└── uploads/ # User-uploaded code archives
|
├── frontend/ # Next.js 16 App Router (bilingual fa-IR / en-US)
|
||||||
|
│ ├── Dockerfile # ARG NEXT_PUBLIC_API_URL baked at build time
|
||||||
|
│ └── src/
|
||||||
|
│ ├── middleware.ts # Locale routing + landing (abrban.com) vs panel split
|
||||||
|
│ ├── app/[lang]/
|
||||||
|
│ │ ├── page.tsx # Landing
|
||||||
|
│ │ ├── login/ register/
|
||||||
|
│ │ ├── blog/
|
||||||
|
│ │ └── dashboard/ # apps, deploy, logs, invoices, wallet, services,
|
||||||
|
│ │ │ # tickets, account, staff, admin
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── components/ hooks/ lib/ (API client, auth store) types/
|
||||||
|
│ └── i18n/ # Dictionaries, provider, language switcher
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start (Local Development)
|
||||||
|
|
||||||
### Prerequisites
|
**Prerequisites:** Node.js ≥ 20, Docker & Docker Compose, and (for actually building/deploying
|
||||||
|
user apps) a Kubernetes cluster reachable via kubeconfig.
|
||||||
|
|
||||||
| Tool | Version |
|
> ℹ️ The API and UI run fine locally against Postgres + Redis. The **build/deploy pipeline
|
||||||
| --------------- | ------- |
|
> itself runs as Kubernetes Jobs**, so triggering a real user-app build requires a cluster
|
||||||
| Node.js | ≥ 20 |
|
> (with the in-cluster registry). For pure UI/API development you don't need one.
|
||||||
| Docker & Compose| ≥ 24 |
|
|
||||||
| PostgreSQL | 16 |
|
|
||||||
| Redis | 7 |
|
|
||||||
| Helm | ≥ 3.12 |
|
|
||||||
|
|
||||||
### 1. Clone & Install
|
### 1. Clone & install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> host && cd host
|
git clone <repo-url> cloud-host && cd cloud-host
|
||||||
cd backend && npm install && cd ..
|
cd backend && npm install && cd ..
|
||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Environment Variables
|
### 2. Environment variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
cp frontend/.env.local.example frontend/.env.local
|
cp frontend/.env.local.example frontend/.env.local
|
||||||
# Edit both files with your DB, JWT, Redis, and registry settings
|
# Edit both — at minimum DB, JWT, Redis. See the Configuration table below.
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Run with Docker Compose
|
### 3. Start Postgres + Redis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up -d postgres redis
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend at port 4000, Frontend at port 3000.
|
### 4. Run the apps
|
||||||
|
|
||||||
### 4. Deploy Platform on Kubernetes (Helm)
|
|
||||||
|
|
||||||
Prerequisites: NGINX Ingress, cert-manager (if TLS enabled), StorageClass for PVCs.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build images (set API URL to match ingress.api.host when TLS is on)
|
# Terminal 1 — Backend (http://localhost:4000, prefix /api/v1, Swagger at /docs)
|
||||||
export REG=your-registry.example.com
|
|
||||||
docker build -t $REG/cloudhost-backend:latest ./backend
|
|
||||||
docker build -t $REG/cloudhost-frontend:latest \
|
|
||||||
--build-arg NEXT_PUBLIC_API_URL=https://api.platform.example.com ./frontend
|
|
||||||
docker push $REG/cloudhost-backend:latest $REG/cloudhost-frontend:latest
|
|
||||||
|
|
||||||
# Install (copy and edit values-production.example.yaml first)
|
|
||||||
helm upgrade --install cloudhost ./backend/helm/cloudhost-platform \
|
|
||||||
-n cloudhost --create-namespace \
|
|
||||||
-f backend/helm/cloudhost-platform/values-production.example.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Key values: `ingress.enabled`, `ingress.tls.enabled`, `ingress.frontend.host`, `ingress.api.host`, `postgres.password`, `secrets.jwtSecret`.
|
|
||||||
|
|
||||||
See chart defaults in `backend/helm/cloudhost-platform/values.yaml` and post-install notes via `helm get notes cloudhost -n cloudhost`.
|
|
||||||
|
|
||||||
### 5. Run Locally (development)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1 — Backend
|
|
||||||
cd backend && npm run start:dev
|
cd backend && npm run start:dev
|
||||||
|
|
||||||
# Terminal 2 — Frontend
|
# Terminal 2 — Frontend (http://localhost:3000)
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
In development `NODE_ENV=development`, so TypeORM `synchronize` builds the schema
|
||||||
|
automatically and the pricing catalog self-seeds. Set `frontend` `NEXT_PUBLIC_API_URL`
|
||||||
|
to the backend URL.
|
||||||
|
|
||||||
## API Endpoints
|
> To run the **whole** stack (API + UI + Postgres + Redis) in containers instead:
|
||||||
|
> `docker compose up --build` (backend on `:4000`, frontend on `:3000`).
|
||||||
All endpoints prefixed with `/api/v1`. Full Swagger docs at `http://localhost:4000/docs`.
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | /auth/register | Create account |
|
|
||||||
| POST | /auth/login | Get JWT tokens |
|
|
||||||
| POST | /auth/refresh | Refresh access token |
|
|
||||||
|
|
||||||
### Applications
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | /applications | Create app |
|
|
||||||
| GET | /applications | List user's apps |
|
|
||||||
| GET | /applications/:id | App details |
|
|
||||||
| PATCH | /applications/:id | Update app |
|
|
||||||
| DELETE | /applications/:id | Delete app + K8s resources |
|
|
||||||
|
|
||||||
### Deployments
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | /applications/:appId/deployments | Trigger deploy |
|
|
||||||
| GET | /applications/:appId/deployments | List deployments |
|
|
||||||
| GET | /deployments/:id | Deployment detail |
|
|
||||||
| GET | /deployments/:id/logs | Pod logs |
|
|
||||||
| POST | /deployments/:id/stop | Stop deployment |
|
|
||||||
| POST | /deployments/:id/restart | Restart deployment |
|
|
||||||
|
|
||||||
### Billing
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | /billing/balance | Get wallet balance |
|
|
||||||
| POST | /billing/deposit | Add funds to wallet |
|
|
||||||
| GET | /billing/transactions | Transaction history |
|
|
||||||
| POST | /billing/pay/:appId | Pay for app plan |
|
|
||||||
|
|
||||||
### Lifecycle (Admin)
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | /lifecycle/settings | Get retention periods |
|
|
||||||
| PATCH | /lifecycle/settings | Update retention periods |
|
|
||||||
|
|
||||||
### Snapshots
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | /snapshots | Create snapshot |
|
|
||||||
| GET | /snapshots | List snapshots |
|
|
||||||
| POST | /snapshots/:id/restore | Restore snapshot |
|
|
||||||
|
|
||||||
### Tickets
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | /tickets | Create ticket |
|
|
||||||
| GET | /tickets | List tickets |
|
|
||||||
| PATCH | /tickets/:id | Update ticket |
|
|
||||||
|
|
||||||
### Users
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | /users/me | Current user |
|
|
||||||
| PATCH | /users/me | Update profile |
|
|
||||||
|
|
||||||
### Admin — Users
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | /users | List all users |
|
|
||||||
| PATCH | /users/:id/activate | Activate user |
|
|
||||||
| PATCH | /users/:id/deactivate | Deactivate user |
|
|
||||||
| PATCH | /users/:id/role | Change role |
|
|
||||||
|
|
||||||
### Admin — Clusters
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | /clusters | Add cluster |
|
|
||||||
| GET | /clusters | List clusters |
|
|
||||||
| GET | /clusters/:id | Cluster details |
|
|
||||||
| PATCH | /clusters/:id | Update cluster |
|
|
||||||
| DELETE | /clusters/:id | Remove cluster |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Deploy on Kubernetes (Helm)
|
||||||
|
|
||||||
|
> This is the **generic** path. For the production `abrban.com` k3s cluster — base-image
|
||||||
|
> mirroring, the Iran-network proxy/npmmirror, the wildcard TLS cert, registry bootstrap,
|
||||||
|
> and the exact image-build flow — follow **[RUNBOOK.fa.md](RUNBOOK.fa.md)**.
|
||||||
|
|
||||||
|
**Prerequisites:** a Kubernetes cluster, an Ingress controller (Traefik on k3s by default,
|
||||||
|
or set `INGRESS_CLASS=nginx`), a default StorageClass for PVCs, and a container registry
|
||||||
|
reachable by the cluster.
|
||||||
|
|
||||||
|
### 1. Build & push the platform images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export REG=your-registry.example.com
|
||||||
|
docker build -t $REG/cloudhost-backend:1.0.0 ./backend
|
||||||
|
docker build -t $REG/cloudhost-frontend:1.0.0 \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL=https://api.platform.example.com ./frontend
|
||||||
|
docker push $REG/cloudhost-backend:1.0.0
|
||||||
|
docker push $REG/cloudhost-frontend:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the control plane
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp backend/helm/cloudhost-platform/values-production.example.yaml my-values.yaml
|
||||||
|
# Edit my-values.yaml: image tags, ingress hosts, postgres password, jwtSecret, registry, SMS/OTP
|
||||||
|
|
||||||
|
helm upgrade --install cloudhost ./backend/helm/cloudhost-platform \
|
||||||
|
-n cloudhost --create-namespace \
|
||||||
|
-f my-values.yaml \
|
||||||
|
--set images.backend.tag=1.0.0 \
|
||||||
|
--set images.frontend.tag=1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Key values: `ingress.enabled`, `ingress.tls.*`, `ingress.frontend.host`, `ingress.api.host`,
|
||||||
|
`postgres.password`, `secrets.jwtSecret`, `migrations.enabled`. Chart defaults live in
|
||||||
|
`backend/helm/cloudhost-platform/values.yaml`; post-install notes via
|
||||||
|
`helm get notes cloudhost -n cloudhost`.
|
||||||
|
|
||||||
|
### 3. Cluster-side prerequisites for the build pipeline
|
||||||
|
|
||||||
|
Ensure the `cloudhost-builds` namespace has:
|
||||||
|
|
||||||
|
- the in-cluster **registry** (`registry:2`) reachable at `REGISTRY_URL`,
|
||||||
|
- a `kaniko-builder` ServiceAccount with an `imagePullSecret` for the registry,
|
||||||
|
- enough ephemeral storage for the per-build source PVC + helper pod.
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
|
||||||
|
```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://<frontend.host>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration (key env vars)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `PORT` | Backend port | `4000` |
|
| `PORT` | Backend port | `4000` |
|
||||||
| `DB_HOST` | PostgreSQL host | `localhost` |
|
| `DB_HOST` / `DB_PORT` / `DB_USERNAME` / `DB_PASSWORD` / `DB_DATABASE` | PostgreSQL connection | `localhost` / `5432` / `cloudhost` / — / `cloudhost` |
|
||||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
| `JWT_SECRET` / `JWT_EXPIRES_IN` | Access token secret + TTL | — / `1h` |
|
||||||
| `DB_USERNAME` | Database user | `cloudhost` |
|
| `JWT_REFRESH_SECRET` / `JWT_REFRESH_EXPIRES_IN` | Refresh token secret + TTL | — / `7d` |
|
||||||
| `DB_PASSWORD` | Database password | — |
|
| `REDIS_HOST` / `REDIS_PORT` | Redis (cache + Bull queues) | `localhost` / `6379` |
|
||||||
| `DB_NAME` | Database name | `cloudhost` |
|
| `SMS_PROVIDER` | OTP provider (`mizbansms` \| `kavenegar`) | `mizbansms` |
|
||||||
| `JWT_SECRET` | JWT signing secret | — |
|
| `MIZBANSMS_USERNAME` / `MIZBANSMS_PASSWORD` / `MIZBANSMS_FROM` | OTP SMS credentials (required or OTP send 503s) | — |
|
||||||
| `JWT_EXPIRES_IN` | Access token TTL | `15m` |
|
| `REGISTRY_URL` / `REGISTRY_PULL_URL` | In-cluster registry (push / pull) | `registry.cloudhost-builds.svc.cluster.local:5000` |
|
||||||
| `REDIS_HOST` | Redis host | `localhost` |
|
| `BUILD_NAMESPACE` / `BUILD_SERVICE_ACCOUNT` | Build Jobs namespace + SA | `cloudhost-builds` / `kaniko-builder` |
|
||||||
| `REDIS_PORT` | Redis port | `6379` |
|
| `KANIKO_IMAGE` | Kaniko executor image | `gcr.io/kaniko-project/executor:v1.23.2` |
|
||||||
| `REGISTRY_URL` | Container registry URL | `localhost:30500` |
|
| `UPLOAD_DIR` | Disk path for uploaded source archives | `./uploads` |
|
||||||
| `PLATFORM_DOMAIN` | Base domain for app subdomains | `apps.cloudhost.ir` |
|
| `INGRESS_CLASS` | Ingress controller for app Ingress objects | `traefik` |
|
||||||
| `LIFECYCLE_SCAN_INTERVAL_MS` | Lifecycle scanner interval | `60000` |
|
| `PLATFORM_DOMAIN` / `PREVIEW_BASE_DOMAIN` | Base domain for app subdomains / previews | `apps.cloudhost.local` / — |
|
||||||
| `LIFECYCLE_HOURLY_DELETE_AFTER_MS` | Hourly plan grace period | `3600000` (1h) |
|
| `PLATFORM_STORAGE_CLASS` | StorageClass for new PVCs (needs volume expansion) | `cloudhost-expandable` |
|
||||||
| `LIFECYCLE_MONTHLY_DELETE_AFTER_MS` | Monthly plan grace period | `259200000` (3d) |
|
| `ELASTICSEARCH_HOST` / `ELASTICSEARCH_PORT` | Log search backend | cluster DNS / `9200` |
|
||||||
| `LIFECYCLE_YEARLY_DELETE_AFTER_MS` | Yearly plan grace period | `604800000` (7d) |
|
| `LIFECYCLE_SCAN_INTERVAL_MS` | Lifecycle scanner tick | `60000` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Login is **mobile-number based**: the user receives a one-time SMS code (OTP) and can also
|
||||||
|
set a password. On every request `JwtStrategy` re-reads the user's **role and active status
|
||||||
|
from the database** (not from the token), so promotions/deactivations take effect immediately.
|
||||||
|
Tokens: JWT access (`JWT_EXPIRES_IN`, default 1h) + refresh (7d).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are prefixed with `/api/v1`. Interactive Swagger docs at
|
||||||
|
`http://localhost:4000/api/docs`. Major route groups: `auth` (OTP request/verify, login,
|
||||||
|
refresh), `applications`, `deployments`, `clusters`, `billing` (wallet, invoices,
|
||||||
|
transactions, pricing), `snapshots`, `tickets`, `users`, `admin`, `notifications`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **JWT** access + refresh tokens with configurable expiry
|
- **JWT** access + refresh tokens; live role/active-status enforcement from DB
|
||||||
- **Bcrypt** password hashing (12 rounds)
|
- **Bcrypt** password hashing
|
||||||
- **Helmet** HTTP security headers
|
- **Helmet** HTTP security headers, **class-validator** on all DTOs
|
||||||
- **RBAC** role-based route guards (`@Roles(UserRole.ADMIN)`)
|
- **RBAC** role-based route guards (`@Roles(...)`)
|
||||||
- **Namespace isolation** — each user deploys to their own K8s namespace
|
- **Namespace isolation** — each user deploys to their own Kubernetes namespace
|
||||||
- **Secrets** — env vars stored as K8s Secrets, never in plain manifests
|
- **Secrets** — env vars stored as K8s Secrets
|
||||||
- **Input validation** — `class-validator` on all DTOs
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# راهنمای Ceph برای ابربان (Rook)
|
||||||
|
|
||||||
|
این سند نصب، معماری واقعی روی کلاستر **abr**، مدیریت روزمره و عیبیابی **Rook-Ceph** را پوشش میدهد.
|
||||||
|
|
||||||
|
- چارت و اسکریپتها: [`backend/helm/cloudhost-ceph/`](backend/helm/cloudhost-ceph/)
|
||||||
|
- README انگلیسی: [`backend/helm/cloudhost-ceph/README.md`](backend/helm/cloudhost-ceph/README.md)
|
||||||
|
- رجیستری: [`RUNBOOK-HARBOR.fa.md`](RUNBOOK-HARBOR.fa.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چرا Ceph؟
|
||||||
|
|
||||||
|
| نیاز | راهحل Ceph |
|
||||||
|
|------|-------------|
|
||||||
|
| PVC اپ/دیتابیس با **resize** | Block pool → StorageClass `rook-ceph-block` |
|
||||||
|
| آپلود **zip** سورس کاربر | Object store (RGW) → StorageClass `rook-ceph-bucket` |
|
||||||
|
|
||||||
|
یک کلاستر Ceph هر دو را پوشش میدهد؛ zip را روی PVC نگه ندارید — از **bucket** استفاده کنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## معماری روی abr (single-node)
|
||||||
|
|
||||||
|
```
|
||||||
|
registry.abrban.com
|
||||||
|
├── rook/ceph:v1.20.1 → Rook operator
|
||||||
|
└── proxy-dockerhub/ceph/ceph:v19.2 → Ceph daemon (Squid)
|
||||||
|
|
||||||
|
Node abr
|
||||||
|
├── /dev/loop6 (15Gi) → OSD (bluestore raw)
|
||||||
|
├── mon-a, mgr-a, osd-0, rgw → rook-ceph namespace
|
||||||
|
└── RGW: rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:80
|
||||||
|
```
|
||||||
|
|
||||||
|
| محدودیت | توضیح |
|
||||||
|
|---------|--------|
|
||||||
|
| **۱ OSD** | replication=1؛ بدون HA |
|
||||||
|
| **loop device** | دیسک خام نداریم؛ `/dev/loop6` از فایل `osd-loopback.img` |
|
||||||
|
| **HEALTH_WARN** | طبیعی: `OSD count 1 < default size 3`، mon low space |
|
||||||
|
| **ایمیجها** | باید از قبل در Harbor mirror شده باشند (kubelet به docker.io دسترسی ندارد) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
### پروفایل `single-node` (کلاستر فعلی abr)
|
||||||
|
|
||||||
|
- Kubernetes 1.28+ (k3s + Traefik)
|
||||||
|
- Harbor بالا و ایمیجهای `rook/ceph` + `ceph/ceph` mirror شده
|
||||||
|
- حداقل **۱۵ گیگ** فضا برای loop OSD (`/var/lib/rook/osd-loopback.img`)
|
||||||
|
- `helm` 3.x و `kubectl` با دسترسی cluster-admin
|
||||||
|
- Secret `registry-pull-secret` در `rook-ceph` با `harbor_registry_user`
|
||||||
|
|
||||||
|
### پروفایل `multi-node` (production)
|
||||||
|
|
||||||
|
- حداقل **۳ نود** + دیسک خام (raw)
|
||||||
|
- فایل values: `values-rook-cluster-multi-node.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نصب (اولین بار — abr)
|
||||||
|
|
||||||
|
### ۱. آمادهسازی loop device برای OSD
|
||||||
|
|
||||||
|
روی نود تکدیسک، Rook به دیسک خام نیاز دارد. یک loop device بسازید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# روی نود (یا Job privileged — یکبار)
|
||||||
|
truncate -s 15G /var/lib/rook/osd-loopback.img
|
||||||
|
losetup --find --show /var/lib/rook/osd-loopback.img # → /dev/loop6
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. فعالسازی loop در Rook operator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n rook-ceph patch cm rook-ceph-operator-config --type merge \
|
||||||
|
-p '{"data":{"ROOK_CEPH_ALLOW_LOOP_DEVICES":"true"}}'
|
||||||
|
kubectl -n rook-ceph rollout restart deploy/rook-ceph-operator
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. mirror ایمیجها (قبل از نصب cluster)
|
||||||
|
|
||||||
|
| ایمیج | مسیر pull |
|
||||||
|
|-------|-----------|
|
||||||
|
| `rook/ceph:v1.20.1` | `registry.abrban.com/rook/ceph:v1.20.1` |
|
||||||
|
| `quay.io/ceph/ceph:v19.2` | `registry.abrban.com/proxy-dockerhub/ceph/ceph:v19.2` |
|
||||||
|
|
||||||
|
جزئیات mirror: [`RUNBOOK-HARBOR.fa.md`](RUNBOOK-HARBOR.fa.md)
|
||||||
|
|
||||||
|
### ۴. نصب operator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add rook-release https://charts.rook.io/release
|
||||||
|
helm repo update rook-release
|
||||||
|
|
||||||
|
helm upgrade --install rook-ceph rook-release/rook-ceph \
|
||||||
|
-n rook-ceph --create-namespace \
|
||||||
|
--set image.repository=registry.abrban.com/rook/ceph \
|
||||||
|
--set image.tag=v1.20.1 \
|
||||||
|
--set imagePullSecrets[0].name=registry-pull-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵. نصب cluster
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/helm/cloudhost-ceph
|
||||||
|
|
||||||
|
helm upgrade --install rook-ceph-cluster rook-release/rook-ceph-cluster \
|
||||||
|
-n rook-ceph \
|
||||||
|
-f values-rook-cluster-single-node.yaml \
|
||||||
|
--set cephClusterSpec.cephVersion.image=registry.abrban.com/proxy-dockerhub/ceph/ceph:v19.2
|
||||||
|
```
|
||||||
|
|
||||||
|
> **توجه:** `values-rook-cluster-single-node.yaml` از `devices: [{name: "/dev/loop6"}]` استفاده میکند (نه directory — در Rook v1.20 حذف شده).
|
||||||
|
|
||||||
|
### ۶. extras (bucket + secret)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create namespace cloudhost-builds 2>/dev/null || true
|
||||||
|
helm upgrade --install cloudhost-ceph . \
|
||||||
|
-n cloudhost-builds -f values.yaml --no-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر Job `bucket-sync` بهخاطر `bitnami/kubectl` گیر کرد، secret را دستی بسازید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost-builds get secret app-sources -o yaml
|
||||||
|
kubectl -n cloudhost-builds get cm app-sources -o yaml # BUCKET_NAME
|
||||||
|
# → secret ceph-app-sources-credentials (کلیدهای SOURCE_STORAGE_*)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۷. یکپارچهسازی backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# کپی secret به namespace پلتفرم (یکبار)
|
||||||
|
kubectl -n cloudhost-builds get secret ceph-app-sources-credentials -o yaml \
|
||||||
|
| sed 's/namespace: cloudhost-builds/namespace: cloudhost/' \
|
||||||
|
| kubectl apply -f -
|
||||||
|
|
||||||
|
# یا با Helm (پیشنهادی):
|
||||||
|
helm upgrade cloudhost ./backend/helm/cloudhost-platform -n cloudhost \
|
||||||
|
--set backend.sourceStorage.enabled=true \
|
||||||
|
--set backend.env.PLATFORM_STORAGE_CLASS=rook-ceph-block \
|
||||||
|
--set backend.env.PLATFORM_CREATE_STORAGE_CLASS=false \
|
||||||
|
--set backend.env.PLATFORM_STORAGE_PROVISIONER=rook-ceph.rbd.csi.ceph.com
|
||||||
|
```
|
||||||
|
|
||||||
|
بدون Helm میتوانید دستی patch کنید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost set env deploy/cloudhost-backend \
|
||||||
|
PLATFORM_STORAGE_CLASS=rook-ceph-block \
|
||||||
|
PLATFORM_CREATE_STORAGE_CLASS=false \
|
||||||
|
PLATFORM_STORAGE_PROVISIONER=rook-ceph.rbd.csi.ceph.com
|
||||||
|
|
||||||
|
kubectl -n cloudhost patch deploy cloudhost-backend --type=json \
|
||||||
|
-p '[{"op":"add","path":"/spec/template/spec/containers/0/envFrom","value":[{"secretRef":{"name":"ceph-app-sources-credentials"}}]}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اسکریپت `install.sh` (نصب تمیز)
|
||||||
|
|
||||||
|
برای نصب از صفر (بعد از آمادهسازی loop + mirror):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/helm/cloudhost-ceph
|
||||||
|
./scripts/install.sh single-node
|
||||||
|
./scripts/verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
اسکریپت operator + cluster + extras را نصب میکند. روی abr حتماً **قبلش** loop device و mirror ایمیج را انجام دهید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خروجیهای مهم
|
||||||
|
|
||||||
|
### StorageClassها
|
||||||
|
|
||||||
|
| نام | کاربرد |
|
||||||
|
|-----|--------|
|
||||||
|
| `rook-ceph-block` | PVC اپ، DB، Redis، … |
|
||||||
|
| `rook-ceph-bucket` | claim کردن bucket برای zip |
|
||||||
|
|
||||||
|
### Secret پلتفرم
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost-builds get secret ceph-app-sources-credentials -o yaml
|
||||||
|
kubectl -n cloudhost get secret ceph-app-sources-credentials -o yaml # کپی در cloudhost
|
||||||
|
```
|
||||||
|
|
||||||
|
کلیدها: `SOURCE_STORAGE_ENDPOINT`, `SOURCE_STORAGE_BUCKET`, `SOURCE_STORAGE_ACCESS_KEY`, `SOURCE_STORAGE_SECRET_KEY`
|
||||||
|
|
||||||
|
### RGW endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc.cluster.local:80
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مدیریت روزمره
|
||||||
|
|
||||||
|
### سلامت کلاستر
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n rook-ceph get cephcluster
|
||||||
|
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph status
|
||||||
|
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd tree
|
||||||
|
kubectl get sc | grep rook-ceph
|
||||||
|
kubectl -n rook-ceph get pods
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n rook-ceph get secret rook-ceph-dashboard-password -o jsonpath='{.data.password}' | base64 -d
|
||||||
|
kubectl -n rook-ceph port-forward svc/rook-ceph-mgr-dashboard 8443:8443
|
||||||
|
# https://localhost:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
### bucket و OBC
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost-builds get obc app-sources
|
||||||
|
kubectl -n cloudhost-builds get cm app-sources
|
||||||
|
```
|
||||||
|
|
||||||
|
### PVC جدید با Ceph
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
storageClassName: rook-ceph-block
|
||||||
|
```
|
||||||
|
|
||||||
|
فقط **اپهای جدید** (یا بعد از migration) از این StorageClass استفاده میکنند. PVCهای قدیمی روی `local-path` / `cloudhost-expandable` خودکار منتقل نمیشوند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## راهاندازی مجدد (reinstall)
|
||||||
|
|
||||||
|
### ۱. حذف Helm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/helm/cloudhost-ceph
|
||||||
|
./scripts/uninstall.sh
|
||||||
|
# تایپ: delete-ceph
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. پاکسازی روی نود
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo losetup -d /dev/loop6 2>/dev/null || true
|
||||||
|
sudo rm -f /var/lib/rook/osd-loopback.img
|
||||||
|
sudo rm -rf /var/lib/rook
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. نصب مجدد
|
||||||
|
|
||||||
|
loop device + mirror + `./scripts/install.sh single-node`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## عیبیابی
|
||||||
|
|
||||||
|
### CephCluster در `Progressing` / Detecting version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n rook-ceph get pods | grep detect-version
|
||||||
|
kubectl -n rook-ceph describe pod -l job-name=rook-ceph-detect-version
|
||||||
|
```
|
||||||
|
|
||||||
|
| خطا | راهحل |
|
||||||
|
|-----|--------|
|
||||||
|
| `ceph/ceph:v19.2 not found` | mirror از quay.io؛ tag صحیح `v19.2` نه `v19.2.1` |
|
||||||
|
| pull timeout | اولین pull بزرگ است (~500MB)؛ صبر یا image را از قبل روی نود بکشید |
|
||||||
|
| Job `detect-version` Terminating گیر کرد | `kubectl -n rook-ceph delete job rook-ceph-detect-version --force --grace-period=0` |
|
||||||
|
|
||||||
|
### OSD بالا نمیآید (OSD count 0)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n rook-ceph logs -l app=rook-ceph-osd-prepare --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
| خطا | راهحل |
|
||||||
|
|-----|--------|
|
||||||
|
| `unsupported diskType loop` | `ROOK_CEPH_ALLOW_LOOP_DEVICES=true` |
|
||||||
|
| `not picked by deviceFilter` | از `devices: [{name: "/dev/loop6"}]` استفاده کنید نه `deviceFilter` |
|
||||||
|
| `no devices matched` | `losetup -a` روی نود؛ loop6 وجود دارد؟ |
|
||||||
|
| `directories` در values | در Rook v1.20 کار نمیکند — loop یا raw disk |
|
||||||
|
|
||||||
|
### Volume mount روی rook-ceph-tools
|
||||||
|
|
||||||
|
`rook-ceph-mon-endpoints` و `rook-ceph-mon` تا قبل از بالا آمدن mon ساخته نمیشوند — طبیعی است؛ بعد از Ready برطرف میشود.
|
||||||
|
|
||||||
|
### Helm timeout روی apiserver
|
||||||
|
|
||||||
|
اگر `failed to download openapi` دیدید، بدون `--wait` نصب کنید و با `kubectl get cephcluster` پیگیری کنید.
|
||||||
|
|
||||||
|
### resize PVC
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get storageclass rook-ceph-block -o yaml | grep allowVolumeExpansion
|
||||||
|
kubectl patch pvc <name> -n <ns> --type merge \
|
||||||
|
-p '{"spec":{"resources":{"requests":{"storage":"5Gi"}}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ارتقا (upgrade)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo update rook-release
|
||||||
|
|
||||||
|
helm upgrade rook-ceph rook-release/rook-ceph -n rook-ceph \
|
||||||
|
--reuse-values --timeout 10m
|
||||||
|
|
||||||
|
helm upgrade rook-ceph-cluster rook-release/rook-ceph-cluster \
|
||||||
|
-n rook-ceph \
|
||||||
|
-f values-rook-cluster-single-node.yaml \
|
||||||
|
--set cephClusterSpec.cephVersion.image=registry.abrban.com/proxy-dockerhub/ceph/ceph:v19.2
|
||||||
|
|
||||||
|
helm upgrade cloudhost-ceph . -n cloudhost-builds -f values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
قبل از upgrade در production: [Rook upgrade guide](https://rook.io/docs/rook/latest/Upgrade/ceph-upgrade/) و snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات امنیتی
|
||||||
|
|
||||||
|
- RGW داخل کلاستر HTTP است — برای دسترسی خارجی ingress + TLS اضافه کنید.
|
||||||
|
- Secret `ceph-app-sources-credentials` را فقط به backend بدهید.
|
||||||
|
- `single-node` + ۱ OSD فقط staging است؛ production نیاز به ۳+ نود و دیسک جدا دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چکلیست بعد از نصب
|
||||||
|
|
||||||
|
- [ ] `ceph status` — mon/mgr/osd/rgw up
|
||||||
|
- [ ] `rook-ceph-block` و `rook-ceph-bucket` در `kubectl get sc`
|
||||||
|
- [ ] `ceph-app-sources-credentials` در `cloudhost-builds` و `cloudhost`
|
||||||
|
- [ ] env بکاند: `PLATFORM_STORAGE_CLASS=rook-ceph-block`
|
||||||
|
- [x] `SOURCE_STORAGE_*` در backend از secret خوانده میشود (`backend.sourceStorage.enabled=true` در Helm)
|
||||||
|
- [ ] اپ تست: آپلود zip و deploy با bucket فعال
|
||||||
|
- [ ] اپ تست با PVC جدید deploy شده
|
||||||
|
- [ ] ایمیجهای Rook در Harbor موجود و pull تست شده
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# راهنمای Harbor — `registry.abrban.com`
|
||||||
|
|
||||||
|
این سند معماری فعلی، نصب، مدیریت روزمره و عیبیابی **Harbor** روی کلاستر abr را پوشش میدهد.
|
||||||
|
|
||||||
|
- چارت/values: [`backend/helm/cloudhost-harbor/`](backend/helm/cloudhost-harbor/)
|
||||||
|
- اسکریپت نصب: [`backend/helm/cloudhost-harbor/scripts/install-harbor-registry.sh`](backend/helm/cloudhost-harbor/scripts/install-harbor-registry.sh)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## معماری فعلی (خلاصه)
|
||||||
|
|
||||||
|
```
|
||||||
|
registry.abrban.com (Traefik + TLS wildcard)
|
||||||
|
├── / → harbor-portal (UI)
|
||||||
|
├── /api/, /service/, /c/ → harbor-core (API + auth)
|
||||||
|
├── /v2/proxy-dockerhub/ → harbor-registry (ایمیجهای mirrorشده Ceph/Rook)
|
||||||
|
├── /v2/rook/ → harbor-registry
|
||||||
|
└── /v2/* → registry قدیمی (ایمیجهای platform: backend, nixpacks, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
| کامپوننت | نقش |
|
||||||
|
|----------|-----|
|
||||||
|
| **Harbor** | UI، proxy-cache، ذخیره ایمیجهای جدید |
|
||||||
|
| **registry قدیمی** (`Deployment/registry`) | هنوز بالاست؛ ایمیجهای platform قبل از Harbor اینجاست |
|
||||||
|
| **registry-egress-proxy** | secret با `HTTP_PROXY` / `HTTPS_PROXY` برای pull از docker.io/quay از داخل کلاستر |
|
||||||
|
| **registry-pull-secret** | auth kubelet برای pull از `registry.abrban.com` |
|
||||||
|
|
||||||
|
> Harbor و registry قدیمی **همزمان** روی یک hostname هستند؛ مسیر `/v2/` با Ingress split میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
- Secret `abrban-wildcard-tls` در namespace `cloudhost`
|
||||||
|
- Secret `registry-egress-proxy` در namespace `cloudhost` (پروکسی egress)
|
||||||
|
- Helm repo: `helm repo add harbor https://helm.goharbor.io`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نصب / ارتقا
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/helm/cloudhost-harbor
|
||||||
|
./scripts/install-harbor-registry.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
اسکریپت:
|
||||||
|
1. Harbor را با values + proxy از `registry-egress-proxy` نصب میکند
|
||||||
|
2. Ingress قدیمی `registry` را حذف میکند (بعد از نصب باید دستی دوباره route شود — بخش Ingress)
|
||||||
|
3. `Deployment/registry` را scale به 0 میکند (برای rollback نگه داشته میشود)
|
||||||
|
|
||||||
|
### Ingress بعد از نصب (الزامی)
|
||||||
|
|
||||||
|
Harbor به **چند مسیر** نیاز دارد. Ingress نهایی باید شبیه این باشد:
|
||||||
|
|
||||||
|
| Path | Service | Port |
|
||||||
|
|------|---------|------|
|
||||||
|
| `/` | `harbor-portal` | 80 |
|
||||||
|
| `/api/`, `/service/`, `/c/`, `/chartrepo/` | `harbor-core` | 80 |
|
||||||
|
| `/v2/proxy-dockerhub/`, `/v2/rook/` | `harbor-registry` | 5000 |
|
||||||
|
| `/v2/` (بقیه) | `registry` (قدیمی) | 5000 |
|
||||||
|
|
||||||
|
بدون split روی `/v2/`، یا UI 404 میدهد یا kubelet ایمیج platform را پیدا نمیکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## دسترسی و credentialها
|
||||||
|
|
||||||
|
| کاربرد | کاربر | منبع |
|
||||||
|
|--------|-------|------|
|
||||||
|
| UI / API مدیریت | `admin` | `kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.HARBOR_ADMIN_PASSWORD}' \| base64 -d` |
|
||||||
|
| push/pull داخلی به harbor-registry | `harbor_registry_user` | secret `harbor-core` → `REGISTRY_CREDENTIAL_PASSWORD` |
|
||||||
|
| pull kubelet (ایمیجهای platform) | `cloudhost` | secret `registry-pull-secret` (namespace `cloudhost`) |
|
||||||
|
| pull kubelet (ایمیجهای Rook/Ceph) | `harbor_registry_user` | secret `registry-pull-secret` (namespace `rook-ceph`) |
|
||||||
|
|
||||||
|
URL: https://registry.abrban.com/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پروژههای proxy-cache
|
||||||
|
|
||||||
|
| پروژه | upstream | کاربرد |
|
||||||
|
|-------|----------|--------|
|
||||||
|
| `proxy-dockerhub` | docker.io | Rook، Ceph، bitnami، … |
|
||||||
|
| `proxy-quay` | quay.io | cephcsi و … |
|
||||||
|
| `proxy-k8s` | registry.k8s.io | CSI sidecarها |
|
||||||
|
|
||||||
|
ایجاد از UI: **Administration → Registries → New Endpoint** سپس **Projects → New Project** با نوع Proxy Cache.
|
||||||
|
|
||||||
|
> health check بعضی endpointها (مثلاً quay) از UI timeout میخورد؛ از داخل `harbor-core` با curl و proxy ممکن است OK باشد. در صورت نیاز endpoint را با type `docker-registry` بسازید.
|
||||||
|
|
||||||
|
### proxy در Harbor
|
||||||
|
|
||||||
|
پروکسی از secret `registry-egress-proxy` در ConfigMapهای `harbor-core` و `harbor-jobservice-env` تزریق میشود.
|
||||||
|
|
||||||
|
**مهم:** Go (harbor-core/jobservice) به `http_proxy` / `https_proxy` **lowercase** هم نیاز دارد. اگر health check upstream `unhealthy` ماند، هر دو حالت uppercase و lowercase را در ConfigMap بگذارید و podها را restart کنید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost rollout restart deploy/harbor-core deploy/harbor-jobservice
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## mirror دستی ایمیج (وقتی proxy-cache کار نمیکند)
|
||||||
|
|
||||||
|
روی abr، pull مستقیم از docker.io/quay از kubelet ممکن نیست. ایمیجهای حیاتی را با Job داخل کلاستر mirror کنید:
|
||||||
|
|
||||||
|
**مقصد push:** `harbor-registry.cloudhost.svc.cluster.local:5000` (HTTP، با `harbor_registry_user`)
|
||||||
|
|
||||||
|
**مثال مسیرها در registry:**
|
||||||
|
|
||||||
|
| ایمیج upstream | مسیر در registry |
|
||||||
|
|----------------|------------------|
|
||||||
|
| `rook/ceph:v1.20.1` | `rook/ceph:v1.20.1` |
|
||||||
|
| `quay.io/ceph/ceph:v19.2` | `proxy-dockerhub/ceph/ceph:v19.2` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# لیست ایمیجهای داخل harbor-registry
|
||||||
|
kubectl -n cloudhost exec deploy/harbor-portal -- \
|
||||||
|
curl -s -u "harbor_registry_user:$(kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)" \
|
||||||
|
http://harbor-registry:5000/v2/_catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
> push مستقیم به `harbor-registry:5000` metadata در Harbor UI را بهروز نمیکند؛ برای kubelet کافی است چون `/v2/` به harbor-registry route شده.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مدیریت روزمره
|
||||||
|
|
||||||
|
### وضعیت
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost get pods | grep harbor
|
||||||
|
kubectl -n cloudhost get ingress registry -o yaml | grep -A3 'path:'
|
||||||
|
curl -sk -o /dev/null -w "%{http_code}\n" https://registry.abrban.com/
|
||||||
|
curl -sk -u admin:<pass> https://registry.abrban.com/api/v2.0/systeminfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### لاگها
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost logs deploy/harbor-core --tail=50
|
||||||
|
kubectl -n cloudhost logs deploy/harbor-jobservice --tail=50
|
||||||
|
kubectl -n cloudhost logs deploy/harbor-registry -c registry --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
### ارتقا Harbor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo update harbor
|
||||||
|
./scripts/install-harbor-registry.sh
|
||||||
|
# Ingress split را دوباره تأیید کنید
|
||||||
|
```
|
||||||
|
|
||||||
|
### rollback به registry قدیمی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cloudhost scale deploy/registry --replicas=1
|
||||||
|
# Ingress را فقط به service registry:5000 برگردانید
|
||||||
|
helm uninstall harbor -n cloudhost
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## عیبیابی
|
||||||
|
|
||||||
|
| علامت | علت محتمل | اقدام |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| `https://registry.abrban.com/` → 404 | Ingress فقط به `harbor-core` وصل است | `/` → `harbor-portal` |
|
||||||
|
| `ImagePullBackOff` برای `cloudhost-backend` | `/v2/` به Harbor رفته، ایمیج platform آنجا نیست | `/v2/` (عمومی) → `registry` قدیمی |
|
||||||
|
| `not found` برای `proxy-dockerhub/...` | ایمیج mirror نشده | Job skopeo یا proxy-cache |
|
||||||
|
| push با 499/503 | Traefik timeout | push از داخل کلاستر به `harbor-registry:5000` |
|
||||||
|
| registry endpoint `unhealthy` | proxy lowercase یا timeout health check | patch ConfigMap + restart؛ یا mirror دستی |
|
||||||
|
| `authentication required` روی pull | pull secret اشتباه namespace | `cloudhost` vs `harbor_registry_user` در `rook-ceph` |
|
||||||
|
|
||||||
|
### تست pull
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# platform (cloudhost user)
|
||||||
|
curl -sk -u "cloudhost:<pass>" https://registry.abrban.com/v2/cloudhost-backend/tags/list
|
||||||
|
|
||||||
|
# rook/ceph (harbor_registry_user)
|
||||||
|
curl -sk -u "harbor_registry_user:<pass>" https://registry.abrban.com/v2/rook/ceph/tags/list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چکلیست بعد از نصب
|
||||||
|
|
||||||
|
- [ ] Portal روی `/` پاسخ 200
|
||||||
|
- [ ] `/api/v2.0/systeminfo` پاسخ JSON
|
||||||
|
- [ ] Ingress split `/v2/` درست است
|
||||||
|
- [ ] پروژههای `proxy-dockerhub`, `proxy-k8s`, `proxy-quay` ساخته شده
|
||||||
|
- [ ] `registry-pull-secret` در `cloudhost` و `rook-ceph` بهروز است
|
||||||
|
- [ ] ایمیجهای Rook/Ceph mirror شده و pull تست شده
|
||||||
@@ -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.
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
# 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 داخلی** | Harbor + registry:2 (legacy) | ایمیجهای build و platform؛ جزئیات: [`RUNBOOK-HARBOR.fa.md`](RUNBOOK-HARBOR.fa.md) |
|
||||||
|
| **Storage (Ceph)** | Rook-Ceph | PVC (`rook-ceph-block`) + bucket zip (`rook-ceph-bucket`)؛ جزئیات: [`RUNBOOK-CEPH.fa.md`](RUNBOOK-CEPH.fa.md) |
|
||||||
|
| **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` → Harbor (UI + proxy-cache) + registry قدیمی برای ایمیجهای platform — [`RUNBOOK-HARBOR.fa.md`](RUNBOOK-HARBOR.fa.md)
|
||||||
|
- `apps.abrban.com` → دامنهی پیشفرض اپهای کاربر
|
||||||
|
|
||||||
|
### ۱.۴ جریان احراز هویت
|
||||||
|
- ورود مبتنی بر **موبایل + OTP** (پیامک از طریق MizbanSMS) یا رمز عبور.
|
||||||
|
- توکن **JWT** (access ~15m، refresh ~7d).
|
||||||
|
- `JwtStrategy` در هر درخواست نقش و فعالبودن کاربر را **از دیتابیس** میخواند (نه از توکن) تا تغییر نقش/غیرفعالسازی بلافاصله اثر کند.
|
||||||
|
|
||||||
|
### ۱.۵ جریان دیپلویِ «اپ کاربر» (مهمترین بخش)
|
||||||
|
وقتی کاربر یک اپ را build/redeploy میکند:
|
||||||
|
|
||||||
|
```
|
||||||
|
کاربر (پنل)
|
||||||
|
│ آپلود zip ──────────────► MinIO (bucket: app-sources) ┐
|
||||||
|
│ یا git URL │ منبع سورس
|
||||||
|
▼ │
|
||||||
|
Backend: یک job در صف Bull («app-deploy») میگذارد │
|
||||||
|
▼ │
|
||||||
|
ساخت یک Kubernetes Job در namespace «cloudhost-builds»: │
|
||||||
|
1) init: fetch-source (دانلود از MinIO) یا git-clone ◄───────┘
|
||||||
|
2) init: nixpacks-prepare
|
||||||
|
• اگر سورس Dockerfile دارد → همان (BYO)
|
||||||
|
• وگرنه → با Nixpacks یک Dockerfile میسازد
|
||||||
|
3) container: Kaniko → build ایمیج → push به registry داخلی
|
||||||
|
▼
|
||||||
|
(غیرمسدودکننده) اسکن Trivy → خلاصهی آسیبپذیری
|
||||||
|
▼
|
||||||
|
Backend با Helm، اپ را روی کلاستر بالا میآورد (Deployment + Service + Ingress)
|
||||||
|
▼
|
||||||
|
GC رجیستری: روزانه فقط N نسخهی آخر هر اپ را نگه میدارد
|
||||||
|
```
|
||||||
|
|
||||||
|
- پیشرفت بیلد در Redis نگهداری میشود؛ فرانت هر ۱.۵ ثانیه `build-progress`/`build-logs` را poll میکند.
|
||||||
|
- توکن git در یک Secret موقتِ هر بیلد مینشیند و در `finally` پاک میشود (در manifest درج نمیشود).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش ۲ — اجرای محلی (Local Dev)
|
||||||
|
|
||||||
|
پیشنیاز: Node 20، Docker، Docker Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ۱) دیتابیس و Redis
|
||||||
|
docker compose up -d # از docker-compose.yml ریشهی پروژه
|
||||||
|
|
||||||
|
# ۲) بکاند
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env # مقادیر را پر کن (DB، JWT، SMS، …)
|
||||||
|
npm install
|
||||||
|
npm run start:dev # روی http://localhost:4000 (پیشوند /api/v1)
|
||||||
|
|
||||||
|
# ۳) فرانت
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # روی http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
- در حالت dev، TypeORM `synchronize` روشن است و جدولها خودکار ساخته میشوند.
|
||||||
|
- `NEXT_PUBLIC_API_URL` فرانت باید به آدرس بکاند اشاره کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش ۳ — استقرار روی کلاستر (abrban / k3s)
|
||||||
|
|
||||||
|
> این بخش فرض میکند کلاستر k3s و wildcard cert از قبل آمادهاند. context کوبه: `default`.
|
||||||
|
|
||||||
|
### ۳.۰ ثابتهای محیط
|
||||||
|
| | مقدار |
|
||||||
|
|---|---|
|
||||||
|
| namespace اپ | `cloudhost` |
|
||||||
|
| namespace بیلد | `cloudhost-builds` |
|
||||||
|
| Helm release | `cloudhost` |
|
||||||
|
| چارت | `backend/helm/cloudhost-platform` |
|
||||||
|
| values | `/tmp/abrban/values-abrban.yaml` |
|
||||||
|
| رجیستری (push داخلی) | `registry.cloudhost.svc.cluster.local:5000` (HTTP, insecure) |
|
||||||
|
| رجیستری (pull توسط kubelet) | `registry.abrban.com` (HTTPS, wildcard cert) — همان storage |
|
||||||
|
| پروکسی build | `http://builder:<pw>@45.129.38.203:9911` |
|
||||||
|
|
||||||
|
### ۳.۱ نکات شبکهی ایران (چرا کارها اینشکلیاند)
|
||||||
|
- کلاستر به **Let's Encrypt، github، docker.io، ghcr.io، gcr.io** مستقیم نمیرسد (یا خیلی کند).
|
||||||
|
- **gTLS/cert**: دستی، secret `abrban-wildcard-tls` (نه cert-manager).
|
||||||
|
- **npm**: از `registry.npmmirror.com` مستقیم (نه پروکسی).
|
||||||
|
- **ایمیجهای پایه**: اول داخل رجیستری داخلی **mirror** میشوند، بعد استفاده.
|
||||||
|
- **دانلودهای build (apk/nix/pip/…)**: از طریق پروکسی بالا.
|
||||||
|
|
||||||
|
### ۳.۲ گامهای استقرار
|
||||||
|
|
||||||
|
**گام ۱ — بررسی دسترسی کلاستر**
|
||||||
|
```bash
|
||||||
|
kubectl config current-context # باید default باشد
|
||||||
|
kubectl get nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
**گام ۲ — mirror کردن ایمیجهای پایه به رجیستری داخلی**
|
||||||
|
ایمیجهایی که کلاستر مستقیم نمیتواند pull کند را با یک Job داخل کلاستر کپی کن.
|
||||||
|
- برای ایمیجهای **کوچک**: `crane copy <src> registry.cloudhost.svc.cluster.local:5000/<dst> --insecure`
|
||||||
|
- برای ایمیجهای **بزرگ** (مثل nixpacks): از **skopeo** استفاده کن — چون بلاب را با PUT یکجا آپلود میکند و گیر `PROTOCOL_ERROR` آپلود تکهای crane را ندارد:
|
||||||
|
```
|
||||||
|
skopeo copy --override-os linux --override-arch amd64 --dest-tls-verify=false \
|
||||||
|
docker://<src> docker://registry.cloudhost.svc.cluster.local:5000/<dst>
|
||||||
|
```
|
||||||
|
(با `REGISTRY_AUTH_FILE` از secret `kaniko-docker-config` و env پروکسی.)
|
||||||
|
|
||||||
|
ایمیجهای لازم: `minio/minio`, `railwayapp/nixpacks`, `library/alpine:3.19`, `library/postgres`, `library/redis`, و base ای که Nixpacks تولید میکند.
|
||||||
|
|
||||||
|
**گام ۳ — MinIO (ذخیرهی سورس اپها)**
|
||||||
|
بهطور خودکار فقط هنگام **ثبت کلاستر جدید** ساخته میشود؛ روی کلاستر موجود دستی بساز: secret `minio-credentials` (`accesskey`/`secretkey`) + PVC ۲۰Gi + Deployment (`registry.abrban.com/minio/minio:latest`) + Service، همه در `cloudhost-builds`. کردنشال پیشفرض با config بکاند میخواند.
|
||||||
|
|
||||||
|
**گام ۴ — pull-secret برای namespace بیلد**
|
||||||
|
```bash
|
||||||
|
# کپی pull-secret رجیستری به ns بیلد
|
||||||
|
kubectl get secret registry-pull-secret -n cloudhost -o json \
|
||||||
|
| jq '.metadata.namespace="cloudhost-builds" | del(.metadata.uid,.metadata.resourceVersion,.metadata.creationTimestamp)' \
|
||||||
|
| kubectl apply -f -
|
||||||
|
# وصل به هر دو ServiceAccount که pod بیلد ممکن است از آنها استفاده کند
|
||||||
|
kubectl patch sa default -n cloudhost-builds -p '{"imagePullSecrets":[{"name":"registry-pull-secret"}]}'
|
||||||
|
kubectl patch sa kaniko-builder -n cloudhost-builds -p '{"imagePullSecrets":[{"name":"registry-pull-secret"}]}'
|
||||||
|
```
|
||||||
|
> ⚠️ `kaniko-builder` حتماً لازم است: pod بیلد با همین SA اجرا میشود و init container نیکسپکس ایمیجش را از `registry.abrban.com` میکشد.
|
||||||
|
|
||||||
|
**گام ۵ — build ایمیجهای frontend/backend (Kaniko)**
|
||||||
|
سورس را در PVC بیلد (`build-src`) از طریق pod `srcsync` قرار بده، سپس Jobهای Kaniko را اجرا کن.
|
||||||
|
```bash
|
||||||
|
# سینک سورس (از working tree؛ tsbuildinfo و dist را حذف کن!)
|
||||||
|
cd <repo>
|
||||||
|
tar czf - --exclude=node_modules --exclude=.next --exclude=.git frontend \
|
||||||
|
| kubectl exec -i srcsync -n cloudhost -- sh -c 'rm -rf /workspace/frontend && tar xzf - -C /workspace'
|
||||||
|
rm -f backend/tsconfig.tsbuildinfo # ← مهم
|
||||||
|
tar czf - --exclude=node_modules --exclude=dist --exclude=.git backend \
|
||||||
|
| kubectl exec -i srcsync -n cloudhost -- sh -c 'rm -rf /workspace/backend && tar xzf - -C /workspace'
|
||||||
|
|
||||||
|
# build (manifestهای kaniko-*.yaml: registry-mirror + proxy + npmmirror)
|
||||||
|
kubectl apply -f /tmp/abrban/kaniko-frontend-<tag>.yaml
|
||||||
|
kubectl apply -f /tmp/abrban/kaniko-backend-<tag>.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**گام ۶ — استقرار با Helm**
|
||||||
|
```bash
|
||||||
|
helm upgrade cloudhost backend/helm/cloudhost-platform \
|
||||||
|
-n cloudhost -f /tmp/abrban/values-abrban.yaml \
|
||||||
|
--set images.backend.tag=<tag> \
|
||||||
|
--set images.frontend.tag=<tag> \
|
||||||
|
--set migrations.enabled=false # ← مهاجرتها روی DB زنده تداخل دارند
|
||||||
|
```
|
||||||
|
> بدون `--wait` اجرا کن (وگرنه بهخاطر کندیِ pull، status اشتباهاً `failed` میشود درحالیکه rollout موفق است).
|
||||||
|
|
||||||
|
**گام ۷ — bootstrap اسکیمای دیتابیس (فقط روی DB تازه)**
|
||||||
|
در پروداکشن `synchronize` خاموش است. روی DB کاملاً تازه: موقتاً `NODE_ENV=development` کن تا synchronize جدولها را بسازد و pricing خودش seed شود، بعد به `production` برگردان. روی DB موجود، فقط مهاجرتهای idempotent جدید را با یک Job جدا اعمال کن (نه helm hook).
|
||||||
|
|
||||||
|
**گام ۸ — تأیید**
|
||||||
|
```bash
|
||||||
|
kubectl get deploy -n cloudhost # backend/frontend 1/1
|
||||||
|
helm status cloudhost -n cloudhost # STATUS: deployed
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://panel.abrban.com # 307
|
||||||
|
curl -s -X POST https://api.abrban.com/api/v1/auth/otp/request \
|
||||||
|
-H 'Content-Type: application/json' -d '{"phone":"09xxxxxxxxx"}' # {"sent":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش ۴ — envهای کلیدی build pipeline (روی بکاند)
|
||||||
|
اینها در `backend.env` فایل values ست میشوند:
|
||||||
|
|
||||||
|
| env | مقدار/توضیح |
|
||||||
|
|---|---|
|
||||||
|
| `MINIO_SECRET_KEY` | کلید MinIO (هماهنگ با secret) |
|
||||||
|
| `NIXPACKS_IMAGE` | `registry.abrban.com/railwayapp/nixpacks:latest` (mirror) |
|
||||||
|
| `NIXPACKS_BUILD_ENV` | `NPM_CONFIG_REGISTRY=https://registry.npmmirror.com` |
|
||||||
|
| `BUILD_HTTP_PROXY` | پروکسی build (تزریق به Kaniko + init containerها) |
|
||||||
|
| `BUILD_REGISTRY_MIRROR` | `registry.cloudhost.svc.cluster.local:5000` (pull پایه از mirror) |
|
||||||
|
| `BUILD_SCAN_ENABLED` | `false` تا وقتی ایمیج Trivy mirror شود |
|
||||||
|
| `SMS_PROVIDER` + `MIZBANSMS_*` | بدون اینها ارسال OTP خطای 503 میدهد |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش ۵ — عیبیابی رایج
|
||||||
|
|
||||||
|
| نشانه | علت | راهحل |
|
||||||
|
|---|---|---|
|
||||||
|
| backend کرش: `Cannot find module '/app/dist/main.js'` | `tsconfig.tsbuildinfo` کهنه در سورس → tsc فایلها را دوباره emit نمیکند | قبل از build، `tsconfig.tsbuildinfo` را حذف کن |
|
||||||
|
| init container بیلد: `no basic auth credentials` | SA `kaniko-builder` بدون pull-secret | گام ۴ را اجرا کن |
|
||||||
|
| pull ایمیج: `not found` با اینکه push شده | crane در آپلود تکهایِ بلاب بزرگ شکست خورده (tag ناقص) | با **skopeo** دوباره mirror کن |
|
||||||
|
| pull از docker.io: `TLS handshake timeout` | docker.io از کلاستر بسته است | ایمیج را mirror کن |
|
||||||
|
| `nixpacks: not found` در init | باینری نیکسپکس روی PATH پیشفرض نیست | command را با مسیر/ENTRYPOINT درست صدا بزن |
|
||||||
|
| helm: `no template "...namespace"` یا `nil pointer redis.enabled` | فایلهای چارت (`_helpers.tpl`/`values.yaml`) از `/tmp` پاک شدهاند | از سورس اصلی بازیابی + ادیتهای deploy را دوباره اعمال کن |
|
||||||
|
| OTP خطای 503 | env پیامک ست نیست | `SMS_PROVIDER`/`MIZBANSMS_*` را ست کن |
|
||||||
|
|
||||||
|
> ⚠️ پوشهی `/tmp` در macOS فایلهای قدیمیتر از ~۳ روز را پاک میکند؛ درخت کاری deploy در `/tmp` ممکن است فایل از دست بدهد — قبل از build بررسی کن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بخش ۶ — بهروزرسانی نسخه (خلاصه)
|
||||||
|
1. تغییرات کد را در سورس اعمال کن (`tsc --noEmit` بگیر).
|
||||||
|
2. tag جدید انتخاب کن.
|
||||||
|
3. سورس را در `srcsync` سینک کن (با حذف `tsbuildinfo`).
|
||||||
|
4. Job Kaniko را با tag جدید بساز.
|
||||||
|
5. `helm upgrade ... --set images.*.tag=<tag> --set migrations.enabled=false` (بدون `--wait`).
|
||||||
|
6. تأیید کن: podها 1/1، helm `deployed`، endpointها سالم.
|
||||||
@@ -20,6 +20,13 @@ JWT_REFRESH_EXPIRES_IN=7d
|
|||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Multi-cluster: AES-256-GCM key for encrypting kubeconfigs at rest (required in production).
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
CLUSTER_KUBECONFIG_KEY=
|
||||||
|
|
||||||
|
# Stub payment gateway (dev/staging only — disabled in production unless explicitly enabled)
|
||||||
|
# PAYMENT_GATEWAY_STUB_ENABLED=true
|
||||||
|
|
||||||
# ─── OTP SMS ────────────────────────────────────────────────────────────────
|
# ─── OTP SMS ────────────────────────────────────────────────────────────────
|
||||||
# Pick the provider. Without valid credentials, OTP codes are logged to the API
|
# Pick the provider. Without valid credentials, OTP codes are logged to the API
|
||||||
# console in development only; in production a missing config makes OTP send fail
|
# console in development only; in production a missing config makes OTP send fail
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts', 'test/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.rook-cluster-values-ref.yaml
|
||||||
|
*.md
|
||||||
|
scripts/
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
# Default values for a single rook-ceph cluster
|
||||||
|
# This is a YAML-formatted file.
|
||||||
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
|
# -- Namespace of the main rook operator
|
||||||
|
operatorNamespace: rook-ceph
|
||||||
|
|
||||||
|
# -- The metadata.name of the CephCluster CR
|
||||||
|
# @default -- The same as the namespace
|
||||||
|
clusterName:
|
||||||
|
|
||||||
|
# -- Optional override of the target kubernetes version
|
||||||
|
kubeVersion:
|
||||||
|
|
||||||
|
# -- Cluster ceph.conf override
|
||||||
|
configOverride:
|
||||||
|
# configOverride: |
|
||||||
|
# [global]
|
||||||
|
# mon_allow_pool_delete = true
|
||||||
|
# osd_pool_default_size = 3
|
||||||
|
# osd_pool_default_min_size = 2
|
||||||
|
|
||||||
|
# Installs a debugging toolbox deployment
|
||||||
|
toolbox:
|
||||||
|
# -- Enable Ceph debugging pod deployment. See [toolbox](../Troubleshooting/ceph-toolbox.md)
|
||||||
|
enabled: false
|
||||||
|
# -- Toolbox image, defaults to the image used by the Ceph cluster
|
||||||
|
image: #quay.io/ceph/ceph:v20.2.1
|
||||||
|
# -- Toolbox tolerations
|
||||||
|
tolerations: []
|
||||||
|
# -- Toolbox affinity
|
||||||
|
affinity: {}
|
||||||
|
# -- Toolbox labels
|
||||||
|
labels: {}
|
||||||
|
# -- Toolbox container security context
|
||||||
|
containerSecurityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 2016
|
||||||
|
runAsGroup: 2016
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
# -- Toolbox resources
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "128Mi"
|
||||||
|
# -- Set the priority class for the toolbox if desired
|
||||||
|
priorityClassName:
|
||||||
|
|
||||||
|
monitoring:
|
||||||
|
# -- Enable Prometheus integration, will also create necessary RBAC rules to allow Operator to create ServiceMonitors.
|
||||||
|
# Monitoring requires Prometheus to be pre-installed
|
||||||
|
enabled: false
|
||||||
|
# -- Whether to disable the metrics reported by Ceph. If false, the prometheus mgr module and Ceph exporter are enabled
|
||||||
|
metricsDisabled: false
|
||||||
|
# -- Whether to create the Prometheus rules for Ceph alerts
|
||||||
|
createPrometheusRules: false
|
||||||
|
# -- Edit Prometheus rules for Ceph alerts
|
||||||
|
prometheusRuleOverrides: {}
|
||||||
|
# CephHealthWarning:
|
||||||
|
# disabled: true
|
||||||
|
# NVMeoFHighWriteLatency:
|
||||||
|
# for: 3m
|
||||||
|
# labels:
|
||||||
|
# severity: critical
|
||||||
|
# -- The namespace in which to create the prometheus rules, if different from the rook cluster namespace.
|
||||||
|
# If you have multiple rook-ceph clusters in the same k8s cluster, choose the same namespace (ideally, namespace with prometheus
|
||||||
|
# deployed) to set rulesNamespaceOverride for all the clusters. Otherwise, you will get duplicate alerts with multiple alert definitions.
|
||||||
|
rulesNamespaceOverride:
|
||||||
|
# Monitoring settings for external clusters:
|
||||||
|
# externalMgrEndpoints: <list of endpoints>
|
||||||
|
# externalMgrPrometheusPort: <port>
|
||||||
|
# Scrape interval for prometheus
|
||||||
|
# interval: 10s
|
||||||
|
# allow adding custom labels and annotations to the prometheus rule
|
||||||
|
prometheusRule:
|
||||||
|
# -- Labels applied to PrometheusRule
|
||||||
|
labels: {}
|
||||||
|
# -- Annotations applied to PrometheusRule
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
# imagePullSecrets option allow to pull docker images from private docker registry. Option will be passed to all service accounts.
|
||||||
|
# imagePullSecrets:
|
||||||
|
# - name: my-registry-secret
|
||||||
|
|
||||||
|
# Labels and annotations to add to the CephCluster CR
|
||||||
|
cephClusterMetadata:
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
|
||||||
|
# Specify these values to override the Ceph image in the cephClusterSpec below.
|
||||||
|
# If specifying these values, do not include the cephVersion section in the cephClusterSpec.
|
||||||
|
cephImage:
|
||||||
|
# The repository from which to pull the ceph image
|
||||||
|
repository: quay.io/ceph/ceph
|
||||||
|
# In production, use a specific version tag instead of the general v20 flag, which pulls the latest release and could result in different
|
||||||
|
# versions running within the cluster. See tags available at https://hub.docker.com/r/ceph/ceph/tags/.
|
||||||
|
# To be more precise, you can always use a timestamp tag such as quay.io/ceph/ceph:v20.2.1-20260402
|
||||||
|
tag: v20.2.1
|
||||||
|
# Whether to allow unsupported versions of Ceph. Currently Squid and Tentacle are supported.
|
||||||
|
# Future versions would require this to be set to `true`.
|
||||||
|
# Do not set to true in production.
|
||||||
|
allowUnsupported: false
|
||||||
|
# The image pull policy for pulling the ceph image in the ceph daemon pods, defaults to IfNotPresent
|
||||||
|
# imagePullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# All values below are taken from the CephCluster CRD
|
||||||
|
# -- Cluster configuration.
|
||||||
|
# @default -- See [below](#ceph-cluster-spec)
|
||||||
|
cephClusterSpec:
|
||||||
|
# This cluster spec example is for a converged cluster where all the Ceph daemons are running locally,
|
||||||
|
# as in the host-based example (cluster.yaml). For a different configuration such as a
|
||||||
|
# PVC-based cluster (cluster-on-pvc.yaml), external cluster (cluster-external.yaml),
|
||||||
|
# or stretch cluster (cluster-stretched.yaml), replace this entire `cephClusterSpec`
|
||||||
|
# with the specs from those examples.
|
||||||
|
# For more details, check https://rook.io/docs/rook/v1.10/CRDs/Cluster/ceph-cluster-crd/
|
||||||
|
|
||||||
|
# The path on the host where configuration files will be persisted. Must be specified. If there are multiple clusters, the directory must be unique for each cluster.
|
||||||
|
# Important: if you reinstall the cluster, make sure you delete this directory from each host or else the mons will fail to start on the new cluster.
|
||||||
|
# In Minikube, the '/data' directory is configured to persist across reboots. Use "/data/rook" in Minikube environment.
|
||||||
|
dataDirHostPath: /var/lib/rook
|
||||||
|
|
||||||
|
# Whether or not upgrade should continue even if a check fails
|
||||||
|
# This means Ceph's status could be degraded and we don't recommend upgrading but you might decide otherwise
|
||||||
|
# Use at your OWN risk
|
||||||
|
# To understand Rook's upgrade process of Ceph, read https://rook.io/docs/rook/v1.10/Upgrade/ceph-upgrade/
|
||||||
|
skipUpgradeChecks: false
|
||||||
|
|
||||||
|
# Whether or not continue if PGs are not clean during an upgrade
|
||||||
|
continueUpgradeAfterChecksEvenIfNotHealthy: false
|
||||||
|
|
||||||
|
# WaitTimeoutForHealthyOSDInMinutes defines the time (in minutes) the operator would wait before an OSD can be stopped for upgrade or restart.
|
||||||
|
# If the timeout exceeds and OSD is not ok to stop, then the operator would skip upgrade for the current OSD and proceed with the next one
|
||||||
|
# if `continueUpgradeAfterChecksEvenIfNotHealthy` is `false`. If `continueUpgradeAfterChecksEvenIfNotHealthy` is `true`, then operator would
|
||||||
|
# continue with the upgrade of an OSD even if its not ok to stop after the timeout. This timeout won't be applied if `skipUpgradeChecks` is `true`.
|
||||||
|
# The default wait timeout is 10 minutes.
|
||||||
|
waitTimeoutForHealthyOSDInMinutes: 10
|
||||||
|
|
||||||
|
# Whether or not requires PGs are clean before an OSD upgrade. If set to `true` OSD upgrade process won't start until PGs are healthy.
|
||||||
|
# This configuration will be ignored if `skipUpgradeChecks` is `true`.
|
||||||
|
# Default is false.
|
||||||
|
upgradeOSDRequiresHealthyPGs: false
|
||||||
|
|
||||||
|
mon:
|
||||||
|
# Set the number of mons to be started. Generally recommended to be 3.
|
||||||
|
# For highest availability, an odd number of mons should be specified.
|
||||||
|
count: 3
|
||||||
|
# The mons should be on unique nodes. For production, at least 3 nodes are recommended for this reason.
|
||||||
|
# Mons should only be allowed on the same node for test environments where data loss is acceptable.
|
||||||
|
allowMultiplePerNode: false
|
||||||
|
|
||||||
|
mgr:
|
||||||
|
# When higher availability of the mgr is needed, increase the count to 2.
|
||||||
|
# In that case, one mgr will be active and one in standby. When Ceph updates which
|
||||||
|
# mgr is active, Rook will update the mgr services to match the active mgr.
|
||||||
|
count: 2
|
||||||
|
allowMultiplePerNode: false
|
||||||
|
modules:
|
||||||
|
# List of modules to optionally enable or disable.
|
||||||
|
# Note the "dashboard" and "monitoring" modules are already configured by other settings in the cluster CR.
|
||||||
|
# - name: rook
|
||||||
|
# enabled: true
|
||||||
|
|
||||||
|
# enable the ceph dashboard for viewing cluster status
|
||||||
|
dashboard:
|
||||||
|
enabled: true
|
||||||
|
# serve the dashboard under a subpath (useful when you are accessing the dashboard via a reverse proxy)
|
||||||
|
# urlPrefix: /ceph-dashboard
|
||||||
|
# serve the dashboard at the given port.
|
||||||
|
# port: 8443
|
||||||
|
# Serve the dashboard using SSL (if using ingress to expose the dashboard and `ssl: true` you need to set
|
||||||
|
# the corresponding "backend protocol" annotation(s) for your ingress controller of choice)
|
||||||
|
ssl: true
|
||||||
|
|
||||||
|
# Network configuration, see: https://github.com/rook/rook/blob/master/Documentation/CRDs/Cluster/ceph-cluster-crd.md#network-configuration-settings
|
||||||
|
network:
|
||||||
|
connections:
|
||||||
|
# Whether to encrypt the data in transit across the wire to prevent eavesdropping the data on the network.
|
||||||
|
# The default is false. When encryption is enabled, all communication between clients and Ceph daemons, or between Ceph daemons will be encrypted.
|
||||||
|
# When encryption is not enabled, clients still establish a strong initial authentication and data integrity is still validated with a crc check.
|
||||||
|
# IMPORTANT: Encryption requires the 5.11 kernel for the latest nbd and cephfs drivers. Alternatively for testing only,
|
||||||
|
# you can set the "mounter: rbd-nbd" in the rbd storage class, or "mounter: fuse" in the cephfs storage class.
|
||||||
|
# The nbd and fuse drivers are *not* recommended in production since restarting the csi driver pod will disconnect the volumes.
|
||||||
|
encryption:
|
||||||
|
enabled: false
|
||||||
|
# Whether to compress the data in transit across the wire. The default is false.
|
||||||
|
# The kernel requirements above for encryption also apply to compression.
|
||||||
|
compression:
|
||||||
|
enabled: false
|
||||||
|
# Whether to require communication over msgr2. If true, the msgr v1 port (6789) will be disabled
|
||||||
|
# and clients will be required to connect to the Ceph cluster with the v2 port (3300).
|
||||||
|
# Requires a kernel that supports msgr v2 (kernel 5.11 or CentOS 8.4 or newer).
|
||||||
|
requireMsgr2: false
|
||||||
|
# # enable host networking
|
||||||
|
# provider: host
|
||||||
|
# # EXPERIMENTAL: enable the Multus network provider
|
||||||
|
# provider: multus
|
||||||
|
# selectors:
|
||||||
|
# # The selector keys are required to be `public` and `cluster`.
|
||||||
|
# # Based on the configuration, the operator will do the following:
|
||||||
|
# # 1. if only the `public` selector key is specified both public_network and cluster_network Ceph settings will listen on that interface
|
||||||
|
# # 2. if both `public` and `cluster` selector keys are specified the first one will point to 'public_network' flag and the second one to 'cluster_network'
|
||||||
|
# #
|
||||||
|
# # In order to work, each selector value must match a NetworkAttachmentDefinition object in Multus
|
||||||
|
# #
|
||||||
|
# # public: public-conf --> NetworkAttachmentDefinition object name in Multus
|
||||||
|
# # cluster: cluster-conf --> NetworkAttachmentDefinition object name in Multus
|
||||||
|
# # Provide internet protocol version. IPv6, IPv4 or empty string are valid options. Empty string would mean IPv4
|
||||||
|
# ipFamily: "IPv6"
|
||||||
|
# # Ceph daemons to listen on both IPv4 and Ipv6 networks
|
||||||
|
# dualStack: false
|
||||||
|
|
||||||
|
# enable the crash collector for ceph daemon crash collection
|
||||||
|
crashCollector:
|
||||||
|
disable: false
|
||||||
|
# Uncomment daysToRetain to prune ceph crash entries older than the
|
||||||
|
# specified number of days.
|
||||||
|
# daysToRetain: 30
|
||||||
|
|
||||||
|
# enable log collector, daemons will log on files and rotate
|
||||||
|
logCollector:
|
||||||
|
enabled: true
|
||||||
|
periodicity: daily # one of: hourly, daily, weekly, monthly
|
||||||
|
maxLogSize: 500M # SUFFIX may be 'M' or 'G'. Must be at least 1M.
|
||||||
|
|
||||||
|
# automate [data cleanup process](https://github.com/rook/rook/blob/master/Documentation/Storage-Configuration/ceph-teardown.md#delete-the-data-on-hosts) in cluster destruction.
|
||||||
|
cleanupPolicy:
|
||||||
|
# Since cluster cleanup is destructive to data, confirmation is required.
|
||||||
|
# To destroy all Rook data on hosts during uninstall, confirmation must be set to "yes-really-destroy-data".
|
||||||
|
# This value should only be set when the cluster is about to be deleted. After the confirmation is set,
|
||||||
|
# Rook will immediately stop configuring the cluster and only wait for the delete command.
|
||||||
|
# If the empty string is set, Rook will not destroy any data on hosts during uninstall.
|
||||||
|
confirmation: ""
|
||||||
|
# sanitizeDisks represents settings for sanitizing OSD disks on cluster deletion
|
||||||
|
sanitizeDisks:
|
||||||
|
# method indicates if the entire disk should be sanitized or simply ceph's metadata
|
||||||
|
# in both case, re-install is possible
|
||||||
|
# possible choices are 'complete' or 'quick' (default)
|
||||||
|
method: quick
|
||||||
|
# dataSource indicate where to get random bytes from to write on the disk
|
||||||
|
# possible choices are 'zero' (default) or 'random'
|
||||||
|
# using random sources will consume entropy from the system and will take much more time then the zero source
|
||||||
|
dataSource: zero
|
||||||
|
# iteration overwrite N times instead of the default (1)
|
||||||
|
# takes an integer value
|
||||||
|
iteration: 1
|
||||||
|
# allowUninstallWithVolumes defines how the uninstall should be performed
|
||||||
|
# If set to true, cephCluster deletion does not wait for the PVs to be deleted.
|
||||||
|
allowUninstallWithVolumes: false
|
||||||
|
|
||||||
|
# To control where various services will be scheduled by kubernetes, use the placement configuration sections below.
|
||||||
|
# The example under 'all' would have all services scheduled on kubernetes nodes labeled with 'role=storage-node' and
|
||||||
|
# tolerate taints with a key of 'storage-node'.
|
||||||
|
# placement:
|
||||||
|
# all:
|
||||||
|
# nodeAffinity:
|
||||||
|
# requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
# nodeSelectorTerms:
|
||||||
|
# - matchExpressions:
|
||||||
|
# - key: role
|
||||||
|
# operator: In
|
||||||
|
# values:
|
||||||
|
# - storage-node
|
||||||
|
# podAffinity:
|
||||||
|
# podAntiAffinity:
|
||||||
|
# topologySpreadConstraints:
|
||||||
|
# tolerations:
|
||||||
|
# - key: storage-node
|
||||||
|
# operator: Exists
|
||||||
|
# # The above placement information can also be specified for mon, osd, and mgr components
|
||||||
|
# mon:
|
||||||
|
# # Monitor deployments may contain an anti-affinity rule for avoiding monitor
|
||||||
|
# # collocation on the same node. This is a required rule when host network is used
|
||||||
|
# # or when AllowMultiplePerNode is false. Otherwise this anti-affinity rule is a
|
||||||
|
# # preferred rule with weight: 50.
|
||||||
|
# osd:
|
||||||
|
# mgr:
|
||||||
|
# cleanup:
|
||||||
|
|
||||||
|
# annotations:
|
||||||
|
# all:
|
||||||
|
# mon:
|
||||||
|
# osd:
|
||||||
|
# cleanup:
|
||||||
|
# prepareosd:
|
||||||
|
# # If no mgr annotations are set, prometheus scrape annotations will be set by default.
|
||||||
|
# mgr:
|
||||||
|
# dashboard:
|
||||||
|
|
||||||
|
# labels:
|
||||||
|
# all:
|
||||||
|
# mon:
|
||||||
|
# osd:
|
||||||
|
# cleanup:
|
||||||
|
# mgr:
|
||||||
|
# prepareosd:
|
||||||
|
# # monitoring is a list of key-value pairs. It is injected into all the monitoring resources created by operator.
|
||||||
|
# # These labels can be passed as LabelSelector to Prometheus
|
||||||
|
# monitoring:
|
||||||
|
# dashboard:
|
||||||
|
|
||||||
|
resources:
|
||||||
|
mgr:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
mon:
|
||||||
|
limits:
|
||||||
|
memory: "2Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "1000m"
|
||||||
|
memory: "1Gi"
|
||||||
|
osd:
|
||||||
|
limits:
|
||||||
|
memory: "4Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "1000m"
|
||||||
|
memory: "4Gi"
|
||||||
|
prepareosd:
|
||||||
|
# limits: It is not recommended to set limits on the OSD prepare job
|
||||||
|
# since it's a one-time burst for memory that must be allowed to
|
||||||
|
# complete without an OOM kill. Note however that if a k8s
|
||||||
|
# limitRange guardrail is defined external to Rook, the lack of
|
||||||
|
# a limit here may result in a sync failure, in which case a
|
||||||
|
# limit should be added. 1200Mi may suffice for up to 15Ti
|
||||||
|
# OSDs ; for larger devices 2Gi may be required.
|
||||||
|
# cf. https://github.com/rook/rook/pull/11103
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "50Mi"
|
||||||
|
mgr-sidecar:
|
||||||
|
limits:
|
||||||
|
memory: "100Mi"
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "40Mi"
|
||||||
|
crashcollector:
|
||||||
|
limits:
|
||||||
|
memory: "60Mi"
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "60Mi"
|
||||||
|
logcollector:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "100Mi"
|
||||||
|
cleanup:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "100Mi"
|
||||||
|
exporter:
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
requests:
|
||||||
|
cpu: "50m"
|
||||||
|
memory: "50Mi"
|
||||||
|
cmd-reporter:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "100Mi"
|
||||||
|
|
||||||
|
# The option to automatically remove OSDs that are out and are safe to destroy.
|
||||||
|
removeOSDsIfOutAndSafeToRemove: false
|
||||||
|
|
||||||
|
# priority classes to apply to ceph resources
|
||||||
|
priorityClassNames:
|
||||||
|
mon: system-node-critical
|
||||||
|
osd: system-node-critical
|
||||||
|
mgr: system-cluster-critical
|
||||||
|
|
||||||
|
storage: # cluster level storage configuration and selection
|
||||||
|
useAllNodes: true
|
||||||
|
useAllDevices: true
|
||||||
|
# deviceFilter:
|
||||||
|
# config:
|
||||||
|
# crushRoot: "custom-root" # specify a non-default root label for the CRUSH map
|
||||||
|
# metadataDevice: "md0" # specify a non-rotational storage so ceph-volume will use it as block db device of bluestore.
|
||||||
|
# databaseSizeMB: "1024" # uncomment if the disks are smaller than 100 GB
|
||||||
|
# osdsPerDevice: "1" # this value can be overridden at the node or device level
|
||||||
|
# encryptedDevice: "true" # the default value for this option is "false"
|
||||||
|
# # Individual nodes and their config can be specified as well, but 'useAllNodes' above must be set to false. Then, only the named
|
||||||
|
# # nodes below will be used as storage resources. Each node's 'name' field should match their 'kubernetes.io/hostname' label.
|
||||||
|
# nodes:
|
||||||
|
# - name: "172.17.4.201"
|
||||||
|
# devices: # specific devices to use for storage can be specified for each node
|
||||||
|
# - name: "sdb"
|
||||||
|
# - name: "nvme01" # multiple osds can be created on high performance devices
|
||||||
|
# config:
|
||||||
|
# osdsPerDevice: "5"
|
||||||
|
# - name: "/dev/disk/by-id/ata-ST4000DM004-XXXX" # devices can be specified using full udev paths
|
||||||
|
# config: # configuration can be specified at the node level which overrides the cluster level config
|
||||||
|
# - name: "172.17.4.301"
|
||||||
|
# deviceFilter: "^sd."
|
||||||
|
|
||||||
|
# The section for configuring management of daemon disruptions during upgrade or fencing.
|
||||||
|
disruptionManagement:
|
||||||
|
# If true, the operator will create and manage PodDisruptionBudgets for OSD, Mon, RGW, and MDS daemons. OSD PDBs are managed dynamically
|
||||||
|
# via the strategy outlined in the [design](https://github.com/rook/rook/blob/master/design/ceph/ceph-managed-disruptionbudgets.md). The operator will
|
||||||
|
# block eviction of OSDs by default and unblock them safely when drains are detected.
|
||||||
|
managePodBudgets: true
|
||||||
|
# A duration in minutes that determines how long an entire failureDomain like `region/zone/host` will be held in `noout` (in addition to the
|
||||||
|
# default DOWN/OUT interval) when it is draining. This is only relevant when `managePodBudgets` is `true`. The default value is `30` minutes.
|
||||||
|
osdMaintenanceTimeout: 30
|
||||||
|
|
||||||
|
# Configure the healthcheck and liveness probes for ceph pods.
|
||||||
|
# Valid values for daemons are 'mon', 'osd', 'status'
|
||||||
|
healthCheck:
|
||||||
|
daemonHealth:
|
||||||
|
mon:
|
||||||
|
disabled: false
|
||||||
|
interval: 45s
|
||||||
|
osd:
|
||||||
|
disabled: false
|
||||||
|
interval: 60s
|
||||||
|
status:
|
||||||
|
disabled: false
|
||||||
|
interval: 60s
|
||||||
|
# Change pod liveness probe, it works for all mon, mgr, and osd pods.
|
||||||
|
livenessProbe:
|
||||||
|
mon:
|
||||||
|
disabled: false
|
||||||
|
mgr:
|
||||||
|
disabled: false
|
||||||
|
osd:
|
||||||
|
disabled: false
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
# -- Enable an ingress for the ceph-dashboard
|
||||||
|
dashboard: {}
|
||||||
|
# labels:
|
||||||
|
# external-dns/private: "true"
|
||||||
|
# annotations:
|
||||||
|
# external-dns.alpha.kubernetes.io/hostname: dashboard.example.com
|
||||||
|
# nginx.ingress.kubernetes.io/rewrite-target: /ceph-dashboard/$2
|
||||||
|
# If the dashboard has ssl: true the following will make sure the NGINX Ingress controller can expose the dashboard correctly
|
||||||
|
# nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
|
||||||
|
# nginx.ingress.kubernetes.io/server-snippet: |
|
||||||
|
# proxy_ssl_verify off;
|
||||||
|
# host:
|
||||||
|
# name: dashboard.example.com
|
||||||
|
# path: "/ceph-dashboard(/|$)(.*)"
|
||||||
|
# pathType: Prefix
|
||||||
|
# tls:
|
||||||
|
# - hosts:
|
||||||
|
# - dashboard.example.com
|
||||||
|
# secretName: testsecret-tls
|
||||||
|
## Note: Only one of ingress class annotation or the `ingressClassName:` can be used at a time
|
||||||
|
## to set the ingress class
|
||||||
|
# ingressClassName: nginx
|
||||||
|
|
||||||
|
route:
|
||||||
|
# -- Enable an HTTPRoute for the ceph-dashboard
|
||||||
|
dashboard: {}
|
||||||
|
# labels:
|
||||||
|
# external-dns/private: "true"
|
||||||
|
# annotations:
|
||||||
|
# external-dns.alpha.kubernetes.io/hostname: dashboard.example.com
|
||||||
|
# nginx.ingress.kubernetes.io/rewrite-target: /ceph-dashboard/$2
|
||||||
|
# host:
|
||||||
|
# name: dashboard.example.com
|
||||||
|
# path: "/"
|
||||||
|
# pathType: PathPrefix
|
||||||
|
# parentRefs:
|
||||||
|
# - name: internal
|
||||||
|
# namespace: kube-system
|
||||||
|
# sectionName: https
|
||||||
|
|
||||||
|
# -- A list of CephBlockPool configurations to deploy
|
||||||
|
# @default -- See [below](#ceph-block-pools)
|
||||||
|
cephBlockPools:
|
||||||
|
- name: ceph-blockpool
|
||||||
|
# see https://github.com/rook/rook/blob/master/Documentation/CRDs/Block-Storage/ceph-block-pool-crd.md#spec for available configuration
|
||||||
|
spec:
|
||||||
|
failureDomain: host
|
||||||
|
replicated:
|
||||||
|
size: 3
|
||||||
|
# Enables collecting RBD per-image IO statistics by enabling dynamic OSD performance counters. Defaults to false.
|
||||||
|
# For reference: https://docs.ceph.com/docs/latest/mgr/prometheus/#rbd-io-statistics
|
||||||
|
# enableRBDStats: true
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
name: ceph-block
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
isDefault: true
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
allowVolumeExpansion: true
|
||||||
|
volumeBindingMode: "Immediate"
|
||||||
|
mountOptions: []
|
||||||
|
# see https://kubernetes.io/docs/concepts/storage/storage-classes/#allowed-topologies
|
||||||
|
allowedTopologies: []
|
||||||
|
# - matchLabelExpressions:
|
||||||
|
# - key: rook-ceph-role
|
||||||
|
# values:
|
||||||
|
# - storage-node
|
||||||
|
# see https://github.com/rook/rook/blob/master/Documentation/Storage-Configuration/Block-Storage-RBD/block-storage.md#provision-storage for available configuration
|
||||||
|
parameters:
|
||||||
|
# (optional) mapOptions is a comma-separated list of map options.
|
||||||
|
# For krbd options refer
|
||||||
|
# https://docs.ceph.com/docs/latest/man/8/rbd/#kernel-rbd-krbd-options
|
||||||
|
# For nbd options refer
|
||||||
|
# https://docs.ceph.com/docs/latest/man/8/rbd-nbd/#options
|
||||||
|
# mapOptions: lock_on_read,queue_depth=1024
|
||||||
|
|
||||||
|
# (optional) unmapOptions is a comma-separated list of unmap options.
|
||||||
|
# For krbd options refer
|
||||||
|
# https://docs.ceph.com/docs/latest/man/8/rbd/#kernel-rbd-krbd-options
|
||||||
|
# For nbd options refer
|
||||||
|
# https://docs.ceph.com/docs/latest/man/8/rbd-nbd/#options
|
||||||
|
# unmapOptions: force
|
||||||
|
|
||||||
|
# RBD image format. Defaults to "2".
|
||||||
|
imageFormat: "2"
|
||||||
|
|
||||||
|
# RBD image features, equivalent to OR'd bitfield value: 63
|
||||||
|
# Available for imageFormat: "2". Older releases of CSI RBD
|
||||||
|
# support only the `layering` feature. The Linux kernel (KRBD) supports the
|
||||||
|
# full feature complement as of 5.4
|
||||||
|
imageFeatures: layering
|
||||||
|
|
||||||
|
# These secrets contain Ceph admin credentials.
|
||||||
|
csi.storage.k8s.io/provisioner-secret-name: rook-csi-rbd-provisioner
|
||||||
|
csi.storage.k8s.io/provisioner-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-rbd-provisioner
|
||||||
|
csi.storage.k8s.io/controller-expand-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
csi.storage.k8s.io/controller-publish-secret-name: rook-csi-rbd-provisioner
|
||||||
|
csi.storage.k8s.io/controller-publish-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
csi.storage.k8s.io/node-stage-secret-name: rook-csi-rbd-node
|
||||||
|
csi.storage.k8s.io/node-stage-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
# Specify the filesystem type of the volume. If not specified, csi-provisioner
|
||||||
|
# will set default as `ext4`. Note that `xfs` is not recommended due to potential deadlock
|
||||||
|
# in hyperconverged settings where the volume is mounted on the same node as the osds.
|
||||||
|
csi.storage.k8s.io/fstype: ext4
|
||||||
|
|
||||||
|
# -- A list of CephFileSystem configurations to deploy
|
||||||
|
# @default -- See [below](#ceph-file-systems)
|
||||||
|
cephFileSystems:
|
||||||
|
- name: ceph-filesystem
|
||||||
|
# see https://github.com/rook/rook/blob/master/Documentation/CRDs/Shared-Filesystem/ceph-filesystem-crd.md#filesystem-settings for available configuration
|
||||||
|
spec:
|
||||||
|
metadataPool:
|
||||||
|
replicated:
|
||||||
|
size: 3
|
||||||
|
dataPools:
|
||||||
|
- failureDomain: host
|
||||||
|
replicated:
|
||||||
|
size: 3
|
||||||
|
# Optional and highly recommended, 'data0' by default, see https://github.com/rook/rook/blob/master/Documentation/CRDs/Shared-Filesystem/ceph-filesystem-crd.md#pools
|
||||||
|
name: data0
|
||||||
|
metadataServer:
|
||||||
|
activeCount: 1
|
||||||
|
activeStandby: true
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "4Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "1000m"
|
||||||
|
memory: "4Gi"
|
||||||
|
priorityClassName: system-cluster-critical
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
isDefault: false
|
||||||
|
name: ceph-filesystem
|
||||||
|
# (Optional) specify a data pool to use, must be the name of one of the data pools above, 'data0' by default
|
||||||
|
pool: data0
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
allowVolumeExpansion: true
|
||||||
|
volumeBindingMode: "Immediate"
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
mountOptions: []
|
||||||
|
# see https://github.com/rook/rook/blob/master/Documentation/Storage-Configuration/Shared-Filesystem-CephFS/filesystem-storage.md#provision-storage for available configuration
|
||||||
|
parameters:
|
||||||
|
# The secrets contain Ceph admin credentials.
|
||||||
|
csi.storage.k8s.io/provisioner-secret-name: rook-csi-cephfs-provisioner
|
||||||
|
csi.storage.k8s.io/provisioner-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-cephfs-provisioner
|
||||||
|
csi.storage.k8s.io/controller-expand-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
csi.storage.k8s.io/controller-publish-secret-name: rook-csi-cephfs-provisioner
|
||||||
|
csi.storage.k8s.io/controller-publish-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
csi.storage.k8s.io/node-stage-secret-name: rook-csi-cephfs-node
|
||||||
|
csi.storage.k8s.io/node-stage-secret-namespace: "{{ .Release.Namespace }}"
|
||||||
|
# Specify the filesystem type of the volume. If not specified, csi-provisioner
|
||||||
|
# will set default as `ext4`. Note that `xfs` is not recommended due to potential deadlock
|
||||||
|
# in hyperconverged settings where the volume is mounted on the same node as the osds.
|
||||||
|
csi.storage.k8s.io/fstype: ext4
|
||||||
|
|
||||||
|
# -- Settings for the filesystem snapshot class
|
||||||
|
# @default -- See [CephFS Snapshots](../Storage-Configuration/Ceph-CSI/ceph-csi-snapshot.md#cephfs-snapshots)
|
||||||
|
cephFileSystemVolumeSnapshotClass:
|
||||||
|
enabled: false
|
||||||
|
name: ceph-filesystem
|
||||||
|
isDefault: true
|
||||||
|
deletionPolicy: Delete
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
# see https://rook.io/docs/rook/v1.10/Storage-Configuration/Ceph-CSI/ceph-csi-snapshot/#cephfs-snapshots for available configuration
|
||||||
|
parameters: {}
|
||||||
|
|
||||||
|
# -- Settings for the block pool snapshot class
|
||||||
|
# @default -- See [RBD Snapshots](../Storage-Configuration/Ceph-CSI/ceph-csi-snapshot.md#rbd-snapshots)
|
||||||
|
cephBlockPoolsVolumeSnapshotClass:
|
||||||
|
enabled: false
|
||||||
|
name: ceph-block
|
||||||
|
isDefault: false
|
||||||
|
deletionPolicy: Delete
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
# see https://rook.io/docs/rook/v1.10/Storage-Configuration/Ceph-CSI/ceph-csi-snapshot/#rbd-snapshots for available configuration
|
||||||
|
parameters: {}
|
||||||
|
|
||||||
|
# -- A list of CephObjectStore configurations to deploy
|
||||||
|
# @default -- See [below](#ceph-object-stores)
|
||||||
|
cephObjectStores:
|
||||||
|
- name: ceph-objectstore
|
||||||
|
# see https://github.com/rook/rook/blob/master/Documentation/CRDs/Object-Storage/ceph-object-store-crd.md#object-store-settings for available configuration
|
||||||
|
spec:
|
||||||
|
metadataPool:
|
||||||
|
failureDomain: host
|
||||||
|
replicated:
|
||||||
|
size: 3
|
||||||
|
dataPool:
|
||||||
|
failureDomain: host
|
||||||
|
erasureCoded:
|
||||||
|
dataChunks: 2
|
||||||
|
codingChunks: 1
|
||||||
|
parameters:
|
||||||
|
bulk: "true"
|
||||||
|
preservePoolsOnDelete: true
|
||||||
|
gateway:
|
||||||
|
port: 80
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "2Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "1000m"
|
||||||
|
memory: "1Gi"
|
||||||
|
# securePort: 443
|
||||||
|
# sslCertificateRef:
|
||||||
|
instances: 1
|
||||||
|
priorityClassName: system-cluster-critical
|
||||||
|
# opsLogSidecar:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# memory: "100Mi"
|
||||||
|
# requests:
|
||||||
|
# cpu: "100m"
|
||||||
|
# memory: "40Mi"
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
name: ceph-bucket
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
volumeBindingMode: "Immediate"
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
# see https://github.com/rook/rook/blob/master/Documentation/Storage-Configuration/Object-Storage-RGW/ceph-object-bucket-claim.md#storageclass for available configuration
|
||||||
|
parameters:
|
||||||
|
# note: objectStoreNamespace and objectStoreName are configured by the chart
|
||||||
|
region: us-east-1
|
||||||
|
ingress:
|
||||||
|
# Enable an ingress for the ceph-objectstore
|
||||||
|
enabled: false
|
||||||
|
# The ingress port by default will be the object store's "securePort" (if set), or the gateway "port".
|
||||||
|
# To override those defaults, set this ingress port to the desired port.
|
||||||
|
# port: 80
|
||||||
|
# annotations: {}
|
||||||
|
# host:
|
||||||
|
# name: objectstore.example.com
|
||||||
|
# path: /
|
||||||
|
# pathType: Prefix
|
||||||
|
# tls:
|
||||||
|
# - hosts:
|
||||||
|
# - objectstore.example.com
|
||||||
|
# secretName: ceph-objectstore-tls
|
||||||
|
# ingressClassName: nginx
|
||||||
|
route:
|
||||||
|
# Enable an ingress for the ceph-objectstore
|
||||||
|
enabled: false
|
||||||
|
# The ingress port by default will be the object store's "securePort" (if set), or the gateway "port".
|
||||||
|
# To override those defaults, set this ingress port to the desired port.
|
||||||
|
# port: 80
|
||||||
|
# annotations: {}
|
||||||
|
# host:
|
||||||
|
# name: objectstore.example.com
|
||||||
|
# path: /
|
||||||
|
# pathType: PathPrefix
|
||||||
|
# parentRefs:
|
||||||
|
# - name: internal
|
||||||
|
# namespace: kube-system
|
||||||
|
# sectionName: https
|
||||||
|
## cephECBlockPools are disabled by default, please remove the comments and set desired values to enable it
|
||||||
|
## For erasure coded a replicated metadata pool is required.
|
||||||
|
## https://rook.io/docs/rook/latest/CRDs/Shared-Filesystem/ceph-filesystem-crd/#erasure-coded
|
||||||
|
#cephECBlockPools:
|
||||||
|
# - name: ec-pool
|
||||||
|
# spec:
|
||||||
|
# metadataPool:
|
||||||
|
# replicated:
|
||||||
|
# size: 2
|
||||||
|
# dataPool:
|
||||||
|
# failureDomain: osd
|
||||||
|
# erasureCoded:
|
||||||
|
# dataChunks: 2
|
||||||
|
# codingChunks: 1
|
||||||
|
# deviceClass: hdd
|
||||||
|
#
|
||||||
|
# parameters:
|
||||||
|
# # clusterID is the namespace where the rook cluster is running
|
||||||
|
# # If you change this namespace, also change the namespace below where the secret namespaces are defined
|
||||||
|
# clusterID: rook-ceph # namespace:cluster
|
||||||
|
# # (optional) mapOptions is a comma-separated list of map options.
|
||||||
|
# # For krbd options refer
|
||||||
|
# # https://docs.ceph.com/docs/latest/man/8/rbd/#kernel-rbd-krbd-options
|
||||||
|
# # For nbd options refer
|
||||||
|
# # https://docs.ceph.com/docs/latest/man/8/rbd-nbd/#options
|
||||||
|
# # mapOptions: lock_on_read,queue_depth=1024
|
||||||
|
#
|
||||||
|
# # (optional) unmapOptions is a comma-separated list of unmap options.
|
||||||
|
# # For krbd options refer
|
||||||
|
# # https://docs.ceph.com/docs/latest/man/8/rbd/#kernel-rbd-krbd-options
|
||||||
|
# # For nbd options refer
|
||||||
|
# # https://docs.ceph.com/docs/latest/man/8/rbd-nbd/#options
|
||||||
|
# # unmapOptions: force
|
||||||
|
#
|
||||||
|
# # RBD image format. Defaults to "2".
|
||||||
|
# imageFormat: "2"
|
||||||
|
#
|
||||||
|
# # RBD image features, equivalent to OR'd bitfield value: 63
|
||||||
|
# # Available for imageFormat: "2". Older releases of CSI RBD
|
||||||
|
# # support only the `layering` feature. The Linux kernel (KRBD) supports the
|
||||||
|
# # full feature complement as of 5.4
|
||||||
|
# # imageFeatures: layering,fast-diff,object-map,deep-flatten,exclusive-lock
|
||||||
|
# imageFeatures: layering
|
||||||
|
#
|
||||||
|
# storageClass:
|
||||||
|
# provisioner: rook-ceph.rbd.csi.ceph.com # csi-provisioner-name
|
||||||
|
# enabled: true
|
||||||
|
# name: rook-ceph-block
|
||||||
|
# isDefault: false
|
||||||
|
# annotations: { }
|
||||||
|
# labels: { }
|
||||||
|
# allowVolumeExpansion: true
|
||||||
|
# reclaimPolicy: Delete
|
||||||
|
|
||||||
|
# -- CSI driver name prefix for cephfs, rbd and nfs.
|
||||||
|
# @default -- `namespace name where rook-ceph operator is deployed`
|
||||||
|
csiDriverNamePrefix:
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: cloudhost-ceph
|
||||||
|
description: CloudHost extras on top of Rook-Ceph (app source bucket, platform integration secrets)
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "1.0.0"
|
||||||
|
keywords:
|
||||||
|
- ceph
|
||||||
|
- rook
|
||||||
|
- storage
|
||||||
|
- s3
|
||||||
|
maintainers:
|
||||||
|
- name: CloudHost
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# CloudHost Ceph (Rook)
|
||||||
|
|
||||||
|
Helm chart and install scripts for **Rook-Ceph** on CloudHost clusters:
|
||||||
|
|
||||||
|
| Layer | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| **rook-ceph-block** | Expandable PVCs for apps, databases, registry |
|
||||||
|
| **rook-ceph-bucket** | S3-compatible storage for uploaded source zip archives |
|
||||||
|
|
||||||
|
The chart does **not** vendor Rook itself — it installs the official [`rook-release`](https://charts.rook.io/release) charts and adds CloudHost-specific **ObjectBucketClaim** + credential sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/helm/cloudhost-ceph
|
||||||
|
./scripts/install.sh single-node # one-node k3s (current abr cluster)
|
||||||
|
# or
|
||||||
|
./scripts/install.sh multi-node # production, 3+ nodes + raw disks
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
### `single-node`
|
||||||
|
|
||||||
|
- OSD on **loop device** `/dev/loop6` (15Gi file at `/var/lib/rook/osd-loopback.img`) — no spare raw disk required
|
||||||
|
- Requires `ROOK_CEPH_ALLOW_LOOP_DEVICES=true` on the operator
|
||||||
|
- Replication **size: 1** (no HA)
|
||||||
|
- Suitable for **staging / single k3s node**
|
||||||
|
- Images must be pre-mirrored to `registry.abrban.com` (see `RUNBOOK-HARBOR.fa.md`)
|
||||||
|
|
||||||
|
### `multi-node`
|
||||||
|
|
||||||
|
- OSD on **raw devices** (`useAllDevices: true`)
|
||||||
|
- Replication **size: 3** for block + object metadata
|
||||||
|
- Erasure-coded object data pool
|
||||||
|
- Requires **3+ nodes** and dedicated disks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What gets installed
|
||||||
|
|
||||||
|
| Step | Release | Namespace |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| 1 | `rook-ceph` (operator) | `rook-ceph` |
|
||||||
|
| 2 | `rook-ceph-cluster` | `rook-ceph` |
|
||||||
|
| 3 | `cloudhost-ceph` (OBC + secrets) | `cloudhost-builds` |
|
||||||
|
|
||||||
|
### StorageClasses (from Rook)
|
||||||
|
|
||||||
|
| Name | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `rook-ceph-block` | App PVC, DB PVC, Redis, registry, … |
|
||||||
|
| `rook-ceph-bucket` | `ObjectBucketClaim` → S3 bucket + credentials |
|
||||||
|
|
||||||
|
### CloudHost extras
|
||||||
|
|
||||||
|
| Resource | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ObjectBucketClaim/app-sources` | Bucket for user zip uploads |
|
||||||
|
| `Secret/ceph-app-sources-credentials` | Stable S3 credentials for backend |
|
||||||
|
| `ConfigMap/cloudhost-ceph-integration` | Suggested `PLATFORM_*` env values |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform integration
|
||||||
|
|
||||||
|
After install, configure the **backend**:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PLATFORM_STORAGE_CLASS=rook-ceph-block
|
||||||
|
PLATFORM_CREATE_STORAGE_CLASS=false
|
||||||
|
PLATFORM_STORAGE_PROVISIONER=rook-ceph.rbd.csi.ceph.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount or env-from secret `cloudhost-builds/ceph-app-sources-credentials`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SOURCE_STORAGE_ENDPOINT=http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc.cluster.local:80
|
||||||
|
SOURCE_STORAGE_REGION=us-east-1
|
||||||
|
SOURCE_STORAGE_BUCKET=<from secret>
|
||||||
|
SOURCE_STORAGE_ACCESS_KEY=<from secret>
|
||||||
|
SOURCE_STORAGE_SECRET_KEY=<from secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Existing PVCs on `local-path` / `cloudhost-expandable` are **not** migrated automatically. New apps use `rook-ceph-block` once the backend env is updated. Plan migration per workload (see `RUNBOOK-CEPH.fa.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uninstall (destructive)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/uninstall.sh
|
||||||
|
# then on each node:
|
||||||
|
sudo rm -rf /var/lib/rook /var/lib/rook/osd
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cluster health
|
||||||
|
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph status
|
||||||
|
|
||||||
|
# OSD pods
|
||||||
|
kubectl -n rook-ceph get pods -l app=rook-ceph-osd
|
||||||
|
|
||||||
|
# RGW (object store)
|
||||||
|
kubectl -n rook-ceph get pods -l app=rook-ceph-rgw
|
||||||
|
|
||||||
|
# Bucket sync job
|
||||||
|
kubectl -n cloudhost-builds logs job -l job-name=cloudhost-ceph-bucket-sync --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Full operational guide (Persian): [`../../../RUNBOOK-CEPH.fa.md`](../../../RUNBOOK-CEPH.fa.md)
|
||||||
|
|
||||||
|
Registry / Harbor (Persian): [`../../../RUNBOOK-HARBOR.fa.md`](../../../RUNBOOK-HARBOR.fa.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `values-rook-cluster-single-node.yaml` | Rook cluster values (1 node) |
|
||||||
|
| `values-rook-cluster-multi-node.yaml` | Rook cluster values (production) |
|
||||||
|
| `values.yaml` | CloudHost OBC / secret sync |
|
||||||
|
| `scripts/install.sh` | Full install |
|
||||||
|
| `scripts/verify.sh` | Health check |
|
||||||
|
| `scripts/uninstall.sh` | Tear down |
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install Rook-Ceph operator + cluster + CloudHost bucket extras.
|
||||||
|
# Usage: ./scripts/install.sh [single-node|multi-node]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE="${1:-single-node}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CHART_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
ROOK_NS="rook-ceph"
|
||||||
|
EXTRAS_NS="cloudhost-builds"
|
||||||
|
CLUSTER_VALUES="${CHART_DIR}/values-rook-cluster-${PROFILE}.yaml"
|
||||||
|
|
||||||
|
if [[ ! -f "${CLUSTER_VALUES}" ]]; then
|
||||||
|
echo "Unknown profile: ${PROFILE} (missing ${CLUSTER_VALUES})" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Profile: ${PROFILE}"
|
||||||
|
echo "==> Adding rook-release helm repo"
|
||||||
|
helm repo add rook-release https://charts.rook.io/release 2>/dev/null || true
|
||||||
|
helm repo update rook-release
|
||||||
|
|
||||||
|
echo "==> [1/4] Installing Rook operator in ${ROOK_NS}"
|
||||||
|
helm upgrade --install rook-ceph rook-release/rook-ceph \
|
||||||
|
--namespace "${ROOK_NS}" \
|
||||||
|
--create-namespace \
|
||||||
|
--wait \
|
||||||
|
--timeout 10m
|
||||||
|
|
||||||
|
echo "==> [2/4] Waiting for Rook operator deployment"
|
||||||
|
kubectl -n "${ROOK_NS}" rollout status deploy/rook-ceph-operator --timeout=300s
|
||||||
|
|
||||||
|
echo "==> [3/4] Installing Ceph cluster (${CLUSTER_VALUES})"
|
||||||
|
helm upgrade --install rook-ceph-cluster rook-release/rook-ceph-cluster \
|
||||||
|
--namespace "${ROOK_NS}" \
|
||||||
|
-f "${CLUSTER_VALUES}" \
|
||||||
|
--wait \
|
||||||
|
--timeout 25m
|
||||||
|
|
||||||
|
echo "==> Waiting for CephCluster phase = Ready (up to 20 min)"
|
||||||
|
"${SCRIPT_DIR}/wait-ceph-ready.sh" 1200
|
||||||
|
|
||||||
|
echo "==> [4/4] Installing CloudHost Ceph extras (ObjectBucketClaim) in ${EXTRAS_NS}"
|
||||||
|
kubectl create namespace "${EXTRAS_NS}" 2>/dev/null || true
|
||||||
|
helm upgrade --install cloudhost-ceph "${CHART_DIR}" \
|
||||||
|
--namespace "${EXTRAS_NS}" \
|
||||||
|
-f "${CHART_DIR}/values.yaml" \
|
||||||
|
--wait \
|
||||||
|
--timeout 15m
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Done. Run ./scripts/verify.sh to confirm health and print integration hints."
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Remove CloudHost extras + Rook cluster + operator (DATA LOSS).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
read -r -p "This deletes ALL Ceph data. Type 'delete-ceph' to continue: " CONFIRM
|
||||||
|
if [[ "${CONFIRM}" != "delete-ceph" ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
helm uninstall cloudhost-ceph -n cloudhost-builds 2>/dev/null || true
|
||||||
|
helm uninstall rook-ceph-cluster -n rook-ceph 2>/dev/null || true
|
||||||
|
helm uninstall rook-ceph -n rook-ceph 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Waiting for Rook resources to terminate..."
|
||||||
|
sleep 15
|
||||||
|
kubectl -n rook-ceph get pods 2>/dev/null || true
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
IMPORTANT: On each node, wipe Rook state before reinstalling:
|
||||||
|
sudo rm -rf /var/lib/rook
|
||||||
|
sudo rm -rf /var/lib/rook/osd
|
||||||
|
|
||||||
|
For raw-disk OSDs also zap disks (see RUNBOOK-CEPH.fa.md).
|
||||||
|
EOF
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOK_NS="rook-ceph"
|
||||||
|
EXTRAS_NS="cloudhost-builds"
|
||||||
|
|
||||||
|
echo "=== StorageClasses ==="
|
||||||
|
kubectl get storageclass | grep -E 'NAME|rook-ceph' || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Ceph status ==="
|
||||||
|
kubectl -n "${ROOK_NS}" exec deploy/rook-ceph-tools -- ceph status 2>/dev/null || echo "(tools pod not ready yet)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== OSD / MON pods ==="
|
||||||
|
kubectl -n "${ROOK_NS}" get pods -l app=rook-ceph-osd 2>/dev/null || kubectl -n "${ROOK_NS}" get pods | grep -E 'osd|mon|mgr|rgw' || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Object bucket claim ==="
|
||||||
|
kubectl -n "${EXTRAS_NS}" get obc,app-sources 2>/dev/null || kubectl -n "${EXTRAS_NS}" get obc 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Platform credentials secret ==="
|
||||||
|
if kubectl -n "${EXTRAS_NS}" get secret ceph-app-sources-credentials >/dev/null 2>&1; then
|
||||||
|
echo "Secret ceph-app-sources-credentials exists"
|
||||||
|
kubectl -n "${EXTRAS_NS}" get secret ceph-app-sources-credentials -o jsonpath='{.data.SOURCE_STORAGE_BUCKET}' | base64 -d
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "Secret ceph-app-sources-credentials not ready — check bucket sync job:"
|
||||||
|
kubectl -n "${EXTRAS_NS}" get jobs,pods | grep bucket-sync || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Suggested backend env ==="
|
||||||
|
kubectl -n "${EXTRAS_NS}" get configmap cloudhost-ceph-integration -o yaml 2>/dev/null | sed -n '/PLATFORM_/p;/SOURCE_STORAGE_ENDPOINT/p' || true
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wait until Ceph reports HEALTH_OK or HEALTH_WARN (single-node often stays WARN).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TIMEOUT="${1:-900}"
|
||||||
|
ROOK_NS="rook-ceph"
|
||||||
|
START=$(date +%s)
|
||||||
|
|
||||||
|
echo "Waiting for rook-ceph-tools deployment..."
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if kubectl -n "${ROOK_NS}" get deploy rook-ceph-tools >/dev/null 2>&1; then
|
||||||
|
if kubectl -n "${ROOK_NS}" rollout status deploy/rook-ceph-tools --timeout=120s 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
NOW=$(date +%s)
|
||||||
|
if (( NOW - START > TIMEOUT )); then
|
||||||
|
echo "Timed out after ${TIMEOUT}s waiting for Ceph health" >&2
|
||||||
|
kubectl -n "${ROOK_NS}" get cephcluster,pod -o wide || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kubectl -n "${ROOK_NS}" get deploy rook-ceph-tools >/dev/null 2>&1; then
|
||||||
|
HEALTH=$(kubectl -n "${ROOK_NS}" exec deploy/rook-ceph-tools -- ceph health 2>/dev/null || echo "unknown")
|
||||||
|
echo "Ceph health: ${HEALTH}"
|
||||||
|
if [[ "${HEALTH}" == "HEALTH_OK" || "${HEALTH}" == HEALTH_WARN* ]]; then
|
||||||
|
PHASE=$(kubectl -n "${ROOK_NS}" get cephcluster rook-ceph -o jsonpath='{.status.phase}' 2>/dev/null || echo "")
|
||||||
|
echo "CephCluster phase: ${PHASE}"
|
||||||
|
if [[ "${PHASE}" == "Ready" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CloudHost Ceph storage is ready (or still initializing).
|
||||||
|
|
||||||
|
Profiles
|
||||||
|
single-node Directory OSD on /var/lib/rook/osd — for one-node k3s (no HA)
|
||||||
|
multi-node Raw disk OSDs with replication=3 — production
|
||||||
|
|
||||||
|
StorageClasses created by Rook
|
||||||
|
rook-ceph-block Block volumes (app PVC, DB, registry, …)
|
||||||
|
rook-ceph-bucket S3-compatible buckets via ObjectBucketClaim
|
||||||
|
|
||||||
|
Verify cluster health
|
||||||
|
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph status
|
||||||
|
kubectl get storageclass | grep rook-ceph
|
||||||
|
kubectl -n cloudhost-builds get obc,secret | grep -E 'app-sources|ceph-app-sources'
|
||||||
|
|
||||||
|
Platform backend (after bucket sync Job completes)
|
||||||
|
PLATFORM_STORAGE_CLASS=rook-ceph-block
|
||||||
|
PLATFORM_CREATE_STORAGE_CLASS=false
|
||||||
|
PLATFORM_STORAGE_PROVISIONER=rook-ceph.rbd.csi.ceph.com
|
||||||
|
|
||||||
|
Mount secret cloudhost-builds/ceph-app-sources-credentials for zip upload S3 settings.
|
||||||
|
|
||||||
|
Full guide: backend/helm/cloudhost-ceph/README.md and RUNBOOK-CEPH.fa.md
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
CloudHost Ceph chart helpers
|
||||||
|
*/}}
|
||||||
|
{{- define "cloudhost-ceph.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "cloudhost-ceph.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "cloudhost-ceph.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "cloudhost-ceph.name" . }}-{{ .Chart.Version }}
|
||||||
|
app.kubernetes.io/name: {{ include "cloudhost-ceph.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
app.kubernetes.io/part-of: cloudhost
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{- if .Values.integration.createConfigMap }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ .Values.integration.configMapName }}
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "cloudhost-ceph.labels" . | nindent 4 }}
|
||||||
|
data:
|
||||||
|
PLATFORM_STORAGE_CLASS: rook-ceph-block
|
||||||
|
PLATFORM_CREATE_STORAGE_CLASS: "false"
|
||||||
|
PLATFORM_STORAGE_PROVISIONER: rook-ceph.rbd.csi.ceph.com
|
||||||
|
SOURCE_STORAGE_ENDPOINT: {{ .Values.platform.endpoint | quote }}
|
||||||
|
SOURCE_STORAGE_REGION: {{ .Values.platform.region | quote }}
|
||||||
|
SOURCE_STORAGE_CREDENTIALS_SECRET: {{ .Values.platform.credentialsSecretName | quote }}
|
||||||
|
README: |
|
||||||
|
Block PVCs: set PLATFORM_STORAGE_CLASS=rook-ceph-block on the backend.
|
||||||
|
New app PVCs use rook-ceph-block; existing local-path PVCs are NOT auto-migrated.
|
||||||
|
Object storage credentials: secret {{ .Values.platform.credentialsSecretName }} in {{ .Values.namespace }}.
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{- if .Values.objectStore.claimName }}
|
||||||
|
apiVersion: objectbucket.io/v1alpha1
|
||||||
|
kind: ObjectBucketClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ .Values.objectStore.claimName }}
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "cloudhost-ceph.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "5"
|
||||||
|
spec:
|
||||||
|
storageClassName: {{ .Values.objectStore.bucketStorageClass | quote }}
|
||||||
|
generateBucketName: {{ .Values.objectStore.generateBucketName | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{{- if and .Values.platform.createCredentialsSecret .Values.objectStore.claimName }}
|
||||||
|
# Stable secret name for platform workers. Populated by a post-install Job once the OBC secret exists.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "cloudhost-ceph.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "1"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "1"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["get", "list", "create", "patch", "update"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "1"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
namespace: {{ .Values.namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "cloudhost-ceph.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "10"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
spec:
|
||||||
|
backoffLimit: 30
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "cloudhost-ceph.fullname" . }}-bucket-sync
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
containers:
|
||||||
|
- name: sync
|
||||||
|
image: registry.abrban.com/proxy-dockerhub/bitnami/kubectl:1.32
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: OBC_SECRET
|
||||||
|
value: {{ printf "obc-%s-%s" .Values.namespace .Values.objectStore.claimName | quote }}
|
||||||
|
- name: TARGET_SECRET
|
||||||
|
value: {{ .Values.platform.credentialsSecretName | quote }}
|
||||||
|
- name: NAMESPACE
|
||||||
|
value: {{ .Values.namespace | quote }}
|
||||||
|
- name: ENDPOINT
|
||||||
|
value: {{ .Values.platform.endpoint | quote }}
|
||||||
|
- name: REGION
|
||||||
|
value: {{ .Values.platform.region | quote }}
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
echo "Waiting for OBC secret ${OBC_SECRET} in ${NAMESPACE}..."
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
if kubectl get secret -n "${NAMESPACE}" "${OBC_SECRET}" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
kubectl get secret -n "${NAMESPACE}" "${OBC_SECRET}" >/dev/null
|
||||||
|
|
||||||
|
BUCKET=$(kubectl get secret -n "${NAMESPACE}" "${OBC_SECRET}" -o jsonpath='{.data.BUCKET_NAME}' | base64 -d)
|
||||||
|
ACCESS=$(kubectl get secret -n "${NAMESPACE}" "${OBC_SECRET}" -o jsonpath='{.data.AWS_ACCESS_KEY_ID}' | base64 -d)
|
||||||
|
SECRET=$(kubectl get secret -n "${NAMESPACE}" "${OBC_SECRET}" -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d)
|
||||||
|
|
||||||
|
cat <<EOF | kubectl apply -f -
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ${TARGET_SECRET}
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: cloudhost
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
SOURCE_STORAGE_ENDPOINT: "${ENDPOINT}"
|
||||||
|
SOURCE_STORAGE_REGION: "${REGION}"
|
||||||
|
SOURCE_STORAGE_BUCKET: "${BUCKET}"
|
||||||
|
SOURCE_STORAGE_ACCESS_KEY: "${ACCESS}"
|
||||||
|
SOURCE_STORAGE_SECRET_KEY: "${SECRET}"
|
||||||
|
EOF
|
||||||
|
echo "Synced bucket credentials to secret ${TARGET_SECRET} (bucket=${BUCKET})"
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Rook-Ceph cluster values — MULTI NODE (production).
|
||||||
|
# Install: scripts/install.sh multi-node
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - At least 3 worker nodes (odd mon count)
|
||||||
|
# - Raw disks available (useAllDevices) OR dedicated devices per node
|
||||||
|
# - Taint-free nodes labeled rook-ceph-role=storage-node (optional)
|
||||||
|
|
||||||
|
operatorNamespace: rook-ceph
|
||||||
|
|
||||||
|
toolbox:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
cephClusterSpec:
|
||||||
|
dataDirHostPath: /var/lib/rook
|
||||||
|
|
||||||
|
mon:
|
||||||
|
count: 3
|
||||||
|
allowMultiplePerNode: false
|
||||||
|
|
||||||
|
mgr:
|
||||||
|
count: 2
|
||||||
|
allowMultiplePerNode: false
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
enabled: true
|
||||||
|
ssl: true
|
||||||
|
|
||||||
|
storage:
|
||||||
|
useAllNodes: false
|
||||||
|
useAllDevices: true
|
||||||
|
# Example: pin OSDs to storage nodes only
|
||||||
|
# nodes:
|
||||||
|
# - name: "node-1"
|
||||||
|
# - name: "node-2"
|
||||||
|
# - name: "node-3"
|
||||||
|
|
||||||
|
cephFileSystems: []
|
||||||
|
|
||||||
|
cephBlockPools:
|
||||||
|
- name: ceph-blockpool
|
||||||
|
spec:
|
||||||
|
failureDomain: host
|
||||||
|
replicated:
|
||||||
|
size: 3
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
name: rook-ceph-block
|
||||||
|
isDefault: false
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
allowVolumeExpansion: true
|
||||||
|
volumeBindingMode: WaitForFirstConsumer
|
||||||
|
|
||||||
|
cephObjectStores:
|
||||||
|
- name: ceph-objectstore
|
||||||
|
spec:
|
||||||
|
metadataPool:
|
||||||
|
failureDomain: host
|
||||||
|
replicated:
|
||||||
|
size: 3
|
||||||
|
dataPool:
|
||||||
|
failureDomain: host
|
||||||
|
erasureCoded:
|
||||||
|
dataChunks: 2
|
||||||
|
codingChunks: 1
|
||||||
|
preservePoolsOnDelete: true
|
||||||
|
gateway:
|
||||||
|
port: 80
|
||||||
|
instances: 2
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "2Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "1Gi"
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
name: rook-ceph-bucket
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
parameters:
|
||||||
|
region: us-east-1
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Rook-Ceph cluster values — SINGLE NODE (k3s dev/staging).
|
||||||
|
# Install: scripts/install.sh single-node
|
||||||
|
#
|
||||||
|
# Uses loop device /dev/loop6 (15Gi) on single-node clusters without a spare raw disk.
|
||||||
|
# Replication factor = 1 (no HA). For production multi-node use values-rook-cluster-multi-node.yaml.
|
||||||
|
# See RUNBOOK-CEPH.fa.md for loop setup and image mirroring prerequisites.
|
||||||
|
|
||||||
|
operatorNamespace: rook-ceph
|
||||||
|
|
||||||
|
toolbox:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
cephClusterSpec:
|
||||||
|
dataDirHostPath: /var/lib/rook
|
||||||
|
skipUpgradeChecks: true
|
||||||
|
continueUpgradeAfterChecksEvenIfNotHealthy: true
|
||||||
|
|
||||||
|
mon:
|
||||||
|
count: 1
|
||||||
|
allowMultiplePerNode: true
|
||||||
|
|
||||||
|
mgr:
|
||||||
|
count: 1
|
||||||
|
allowMultiplePerNode: true
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
enabled: true
|
||||||
|
ssl: false
|
||||||
|
|
||||||
|
resources:
|
||||||
|
mon:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "200m"
|
||||||
|
memory: "512Mi"
|
||||||
|
mgr:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "200m"
|
||||||
|
memory: "512Mi"
|
||||||
|
osd:
|
||||||
|
limits:
|
||||||
|
memory: "2Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "1Gi"
|
||||||
|
|
||||||
|
storage:
|
||||||
|
useAllNodes: true
|
||||||
|
useAllDevices: false
|
||||||
|
devices:
|
||||||
|
- name: "/dev/loop6"
|
||||||
|
|
||||||
|
# Disable CephFS to save RAM on single-node clusters.
|
||||||
|
cephFileSystems: []
|
||||||
|
|
||||||
|
cephBlockPools:
|
||||||
|
- name: ceph-blockpool
|
||||||
|
spec:
|
||||||
|
failureDomain: osd
|
||||||
|
replicated:
|
||||||
|
size: 1
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
name: rook-ceph-block
|
||||||
|
isDefault: false
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
allowVolumeExpansion: true
|
||||||
|
volumeBindingMode: WaitForFirstConsumer
|
||||||
|
|
||||||
|
cephObjectStores:
|
||||||
|
- name: ceph-objectstore
|
||||||
|
spec:
|
||||||
|
metadataPool:
|
||||||
|
failureDomain: osd
|
||||||
|
replicated:
|
||||||
|
size: 1
|
||||||
|
dataPool:
|
||||||
|
failureDomain: osd
|
||||||
|
replicated:
|
||||||
|
size: 1
|
||||||
|
preservePoolsOnDelete: true
|
||||||
|
gateway:
|
||||||
|
port: 80
|
||||||
|
instances: 1
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
requests:
|
||||||
|
cpu: "250m"
|
||||||
|
memory: "512Mi"
|
||||||
|
storageClass:
|
||||||
|
enabled: true
|
||||||
|
name: rook-ceph-bucket
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
volumeBindingMode: Immediate
|
||||||
|
parameters:
|
||||||
|
region: us-east-1
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# CloudHost Ceph extras (ObjectBucketClaim for zip uploads).
|
||||||
|
# Rook operator + CephCluster are installed via scripts/install.sh using official rook-release charts.
|
||||||
|
|
||||||
|
namespace: cloudhost-builds
|
||||||
|
|
||||||
|
objectStore:
|
||||||
|
# Must match rook-ceph-cluster cephObjectStores[].storageClass.name
|
||||||
|
bucketStorageClass: rook-ceph-bucket
|
||||||
|
# Claim name; Rook generates bucket + credentials secret
|
||||||
|
claimName: app-sources
|
||||||
|
# Prefix for generated bucket name (Rook appends random suffix)
|
||||||
|
generateBucketName: cloudhost-app-sources
|
||||||
|
|
||||||
|
platform:
|
||||||
|
# Copy S3 credentials into a stable secret name for backend/workers
|
||||||
|
createCredentialsSecret: true
|
||||||
|
credentialsSecretName: ceph-app-sources-credentials
|
||||||
|
# In-cluster RGW endpoint (adjust if ingress is enabled on object store)
|
||||||
|
endpoint: http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc.cluster.local:80
|
||||||
|
region: us-east-1
|
||||||
|
|
||||||
|
integration:
|
||||||
|
# Emit a ConfigMap with suggested backend env vars (non-secret)
|
||||||
|
createConfigMap: true
|
||||||
|
configMapName: cloudhost-ceph-integration
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Replaces registry.abrban.com docker distribution with Harbor.
|
||||||
|
# WARNING: This will delete the existing `Ingress/registry` routing. The old
|
||||||
|
# `Deployment/registry` and its PVC are left in place for rollback.
|
||||||
|
|
||||||
|
VALUES_FILE="${1:-/Users/keyhan/Documents/keyhan-project/cloud-host/backend/helm/cloudhost-harbor/values-registry.abrban.com.yaml}"
|
||||||
|
|
||||||
|
echo "==> Ensuring harbor repo"
|
||||||
|
helm repo add harbor https://helm.goharbor.io 2>/dev/null || true
|
||||||
|
helm repo update harbor
|
||||||
|
|
||||||
|
echo "==> [0/4] Preflight"
|
||||||
|
kubectl -n cloudhost get secret abrban-wildcard-tls >/dev/null
|
||||||
|
kubectl -n cloudhost get secret registry-egress-proxy >/dev/null
|
||||||
|
|
||||||
|
echo "==> [1/4] Disabling old registry ingress (host registry.abrban.com)"
|
||||||
|
kubectl -n cloudhost delete ingress registry --ignore-not-found
|
||||||
|
|
||||||
|
echo "==> [2/4] Scaling old registry deployment down (rollback-friendly)"
|
||||||
|
kubectl -n cloudhost scale deploy/registry --replicas=0 || true
|
||||||
|
|
||||||
|
echo "==> [3/4] Installing Harbor"
|
||||||
|
HTTP_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.HTTP_PROXY}' | base64 -d)"
|
||||||
|
HTTPS_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.HTTPS_PROXY}' | base64 -d)"
|
||||||
|
NO_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.NO_PROXY}' | base64 -d)"
|
||||||
|
|
||||||
|
TMP_PROXY_VALUES="$(mktemp)"
|
||||||
|
cat > "${TMP_PROXY_VALUES}" <<EOF
|
||||||
|
proxy:
|
||||||
|
httpProxy: "${HTTP_PROXY}"
|
||||||
|
httpsProxy: "${HTTPS_PROXY}"
|
||||||
|
noProxy: "${NO_PROXY}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
helm upgrade --install harbor harbor/harbor \
|
||||||
|
-n cloudhost \
|
||||||
|
-f "$VALUES_FILE" \
|
||||||
|
-f "${TMP_PROXY_VALUES}" \
|
||||||
|
--wait \
|
||||||
|
--timeout 20m
|
||||||
|
|
||||||
|
rm -f "${TMP_PROXY_VALUES}" || true
|
||||||
|
|
||||||
|
echo "==> [4/4] Done"
|
||||||
|
kubectl -n cloudhost get ingress | grep -n registry || true
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
## Harbor values to REPLACE registry.abrban.com
|
||||||
|
## Ingress controller on this cluster is Traefik (k3s).
|
||||||
|
##
|
||||||
|
## Install:
|
||||||
|
## helm upgrade --install harbor harbor/harbor -n cloudhost -f backend/helm/cloudhost-harbor/values-registry.abrban.com.yaml
|
||||||
|
##
|
||||||
|
externalURL: https://registry.abrban.com
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
# Values are injected by install script from `cloudhost/registry-egress-proxy`.
|
||||||
|
httpProxy: ""
|
||||||
|
httpsProxy: ""
|
||||||
|
noProxy: ""
|
||||||
|
|
||||||
|
expose:
|
||||||
|
type: ingress
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
certSource: secret
|
||||||
|
secret:
|
||||||
|
secretName: abrban-wildcard-tls
|
||||||
|
ingress:
|
||||||
|
className: traefik
|
||||||
|
hosts:
|
||||||
|
core: registry.abrban.com
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
# Increase timeouts for large pushes (skopeo/registry blobs)
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
|
||||||
|
# Disable components we don't need for now to reduce resources
|
||||||
|
trivy:
|
||||||
|
enabled: false
|
||||||
|
notary:
|
||||||
|
enabled: false
|
||||||
|
chartmuseum:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Single-node staging: keep resource usage modest
|
||||||
|
core:
|
||||||
|
replicas: 1
|
||||||
|
jobservice:
|
||||||
|
replicas: 1
|
||||||
|
registry:
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
persistentVolumeClaim:
|
||||||
|
# Use existing default storage (local-path) until Ceph is ready.
|
||||||
|
# After Ceph, switch to rook-ceph-block for Harbor's PVCs.
|
||||||
|
registry:
|
||||||
|
storageClass: local-path
|
||||||
|
size: 50Gi
|
||||||
|
jobservice:
|
||||||
|
storageClass: local-path
|
||||||
|
size: 5Gi
|
||||||
|
database:
|
||||||
|
storageClass: local-path
|
||||||
|
size: 10Gi
|
||||||
|
redis:
|
||||||
|
storageClass: local-path
|
||||||
|
size: 5Gi
|
||||||
|
|
||||||
|
database:
|
||||||
|
type: internal
|
||||||
|
|
||||||
|
redis:
|
||||||
|
type: internal
|
||||||
|
|
||||||
|
portal:
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
# We will create proxy-cache projects after install (todo: configure-proxy-cache)
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -19,6 +19,10 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: {{ include "cloudhost-platform.backend.fullname" . }}
|
app: {{ include "cloudhost-platform.backend.fullname" . }}
|
||||||
spec:
|
spec:
|
||||||
|
{{- with .Values.backend.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
initContainers:
|
initContainers:
|
||||||
{{- if .Values.postgres.enabled }}
|
{{- if .Values.postgres.enabled }}
|
||||||
- name: wait-postgres
|
- name: wait-postgres
|
||||||
@@ -88,19 +92,24 @@ spec:
|
|||||||
- name: {{ $key }}
|
- name: {{ $key }}
|
||||||
value: {{ $val | quote }}
|
value: {{ $val | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.backend.sourceStorage.enabled }}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: {{ .Values.backend.sourceStorage.existingSecret }}
|
||||||
|
{{- end }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: uploads
|
- name: uploads
|
||||||
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
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: {{ include "cloudhost-platform.frontend.fullname" . }}
|
app: {{ include "cloudhost-platform.frontend.fullname" . }}
|
||||||
spec:
|
spec:
|
||||||
|
{{- with .Values.frontend.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: {{ include "cloudhost-platform.frontendImage" . | quote }}
|
image: {{ include "cloudhost-platform.frontendImage" . | quote }}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
{{- include "cloudhost-platform.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- if .Values.ingress.tls.enabled }}
|
{{- if and .Values.ingress.tls.enabled (not .Values.ingress.tls.secretName) }}
|
||||||
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.clusterIssuer | quote }}
|
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.clusterIssuer | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if and .Values.ingress.singleHost.enabled .Values.ingress.singleHost.apiPath }}
|
{{- if and .Values.ingress.singleHost.enabled .Values.ingress.singleHost.apiPath }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -36,6 +36,10 @@ ingress:
|
|||||||
clusterIssuer: letsencrypt-prod
|
clusterIssuer: letsencrypt-prod
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
# Enable after copying ceph-app-sources-credentials secret into the cloudhost namespace
|
||||||
|
sourceStorage:
|
||||||
|
enabled: false
|
||||||
|
existingSecret: ceph-app-sources-credentials
|
||||||
env:
|
env:
|
||||||
PLATFORM_DOMAIN: apps.example.com
|
PLATFORM_DOMAIN: apps.example.com
|
||||||
REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000
|
REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ redis:
|
|||||||
backend:
|
backend:
|
||||||
enabled: true
|
enabled: true
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
uploads:
|
uploads:
|
||||||
size: 20Gi
|
size: 20Gi
|
||||||
|
sourceStorage:
|
||||||
|
enabled: false
|
||||||
|
existingSecret: ceph-app-sources-credentials
|
||||||
resources: {}
|
resources: {}
|
||||||
extraEnv: {}
|
extraEnv: {}
|
||||||
env:
|
env:
|
||||||
@@ -66,6 +71,8 @@ backend:
|
|||||||
frontend:
|
frontend:
|
||||||
enabled: true
|
enabled: true
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-pull-secret
|
||||||
resources: {}
|
resources: {}
|
||||||
|
|
||||||
# JWT secrets — set in production (values-production.example.yaml)
|
# JWT secrets — set in production (values-production.example.yaml)
|
||||||
@@ -97,3 +104,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'
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Maddy mail server — lightweight full mail server (SMTP + IMAP + DKIM)
|
||||||
|
# Namespace: mail | Host: mail.abrban.com | Primary domain: abrban.com
|
||||||
|
#
|
||||||
|
# Exposed on node IP 78.157.39.52 via k3s servicelb (klipper).
|
||||||
|
# TLS uses the *.abrban.com wildcard cert (secret abrban-wildcard-tls, copied into ns mail).
|
||||||
|
#
|
||||||
|
# NOTE (Iran/IP reputation): inbound mail (receiving) works; outbound delivery to
|
||||||
|
# Gmail/Outlook may be blocked or land in spam, and outbound port 25 may be filtered
|
||||||
|
# by the ISP. Use a smarthost relay if real external delivery is required.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: maddy-config
|
||||||
|
namespace: mail
|
||||||
|
data:
|
||||||
|
maddy.conf: |
|
||||||
|
## Maddy Mail Server - configuration (mail.abrban.com)
|
||||||
|
|
||||||
|
$(hostname) = mail.abrban.com
|
||||||
|
$(primary_domain) = abrban.com
|
||||||
|
$(local_domains) = $(primary_domain)
|
||||||
|
|
||||||
|
tls file /etc/maddy/tls/tls.crt /etc/maddy/tls/tls.key
|
||||||
|
|
||||||
|
# ---- Local storage & authentication ----
|
||||||
|
storage.imapsql local_mailboxes {
|
||||||
|
driver sqlite3
|
||||||
|
dsn imapsql.db
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.pass_table local_authdb {
|
||||||
|
table sql_table {
|
||||||
|
driver sqlite3
|
||||||
|
dsn credentials.db
|
||||||
|
table_name passwords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Routing ----
|
||||||
|
hostname $(hostname)
|
||||||
|
|
||||||
|
table.chain local_rewrites {
|
||||||
|
optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
|
||||||
|
optional_step static {
|
||||||
|
entry postmaster postmaster@$(primary_domain)
|
||||||
|
}
|
||||||
|
optional_step file /data/aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
msgpipeline local_routing {
|
||||||
|
destination postmaster $(local_domains) {
|
||||||
|
modify {
|
||||||
|
replace_rcpt &local_rewrites
|
||||||
|
}
|
||||||
|
deliver_to &local_mailboxes
|
||||||
|
}
|
||||||
|
default_destination {
|
||||||
|
reject 550 5.1.1 "User doesn't exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Inbound SMTP (port 25) ----
|
||||||
|
smtp tcp://0.0.0.0:25 {
|
||||||
|
limits {
|
||||||
|
all rate 20 1s
|
||||||
|
all concurrency 10
|
||||||
|
}
|
||||||
|
dmarc yes
|
||||||
|
check {
|
||||||
|
require_mx_record
|
||||||
|
dkim
|
||||||
|
spf
|
||||||
|
}
|
||||||
|
source $(local_domains) {
|
||||||
|
reject 501 5.1.8 "Use Submission for outgoing SMTP"
|
||||||
|
}
|
||||||
|
default_source {
|
||||||
|
destination postmaster $(local_domains) {
|
||||||
|
deliver_to &local_routing
|
||||||
|
}
|
||||||
|
default_destination {
|
||||||
|
reject 550 5.1.1 "User doesn't exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Submission (ports 465 implicit-TLS, 587 STARTTLS) ----
|
||||||
|
submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
|
||||||
|
limits {
|
||||||
|
all rate 50 1s
|
||||||
|
}
|
||||||
|
auth &local_authdb
|
||||||
|
source $(local_domains) {
|
||||||
|
check {
|
||||||
|
authorize_sender {
|
||||||
|
prepare_email &local_rewrites
|
||||||
|
user_to_email identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
destination postmaster $(local_domains) {
|
||||||
|
deliver_to &local_routing
|
||||||
|
}
|
||||||
|
default_destination {
|
||||||
|
modify {
|
||||||
|
dkim $(primary_domain) $(hostname) default
|
||||||
|
}
|
||||||
|
deliver_to &remote_queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_source {
|
||||||
|
reject 501 5.1.8 "Non-local sender domain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Outbound delivery queue ----
|
||||||
|
target.remote outbound_delivery {
|
||||||
|
limits {
|
||||||
|
destination rate 20 1s
|
||||||
|
destination concurrency 10
|
||||||
|
}
|
||||||
|
mx_auth {
|
||||||
|
dane
|
||||||
|
mtasts {
|
||||||
|
cache fs
|
||||||
|
fs_dir mtasts_cache/
|
||||||
|
}
|
||||||
|
local_policy {
|
||||||
|
min_tls_level encrypted
|
||||||
|
min_mx_level none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.queue remote_queue {
|
||||||
|
target &outbound_delivery
|
||||||
|
autogenerated_msg_domain $(primary_domain)
|
||||||
|
bounce {
|
||||||
|
destination postmaster $(local_domains) {
|
||||||
|
deliver_to &local_routing
|
||||||
|
}
|
||||||
|
default_destination {
|
||||||
|
reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- IMAP (993 implicit-TLS, 143 STARTTLS) ----
|
||||||
|
imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
|
||||||
|
auth &local_authdb
|
||||||
|
storage &local_mailboxes
|
||||||
|
}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: maddy-data
|
||||||
|
namespace: mail
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: maddy
|
||||||
|
namespace: mail
|
||||||
|
labels:
|
||||||
|
app: maddy
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: maddy
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: maddy
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: maddy
|
||||||
|
image: foxcpp/maddy:0.7
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: MADDY_HOSTNAME
|
||||||
|
value: mail.abrban.com
|
||||||
|
- name: MADDY_DOMAIN
|
||||||
|
value: abrban.com
|
||||||
|
ports:
|
||||||
|
- { name: smtp, containerPort: 25 }
|
||||||
|
- { name: submission, containerPort: 587 }
|
||||||
|
- { name: smtps, containerPort: 465 }
|
||||||
|
- { name: imap, containerPort: 143 }
|
||||||
|
- { name: imaps, containerPort: 993 }
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
- name: config
|
||||||
|
mountPath: /data/maddy.conf
|
||||||
|
subPath: maddy.conf
|
||||||
|
- name: tls
|
||||||
|
mountPath: /etc/maddy/tls
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 256Mi
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket: { port: 25 }
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: maddy-data
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: maddy-config
|
||||||
|
- name: tls
|
||||||
|
secret:
|
||||||
|
secretName: abrban-wildcard-tls
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: maddy
|
||||||
|
namespace: mail
|
||||||
|
labels:
|
||||||
|
app: maddy
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
externalTrafficPolicy: Local # preserve client source IP (needed for SPF/spam checks)
|
||||||
|
selector:
|
||||||
|
app: maddy
|
||||||
|
ports:
|
||||||
|
- { name: smtp, port: 25, targetPort: 25 }
|
||||||
|
- { name: submission, port: 587, targetPort: 587 }
|
||||||
|
- { name: smtps, port: 465, targetPort: 465 }
|
||||||
|
- { name: imap, port: 143, targetPort: 143 }
|
||||||
|
- { name: imaps, port: 993, targetPort: 993 }
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# فاز ۰ — Spike ریسک Nixpacks روی شبکهی ایران (abrban / cloudhost-builds)
|
|
||||||
#
|
|
||||||
# هدف: قبل از مهاجرت سیستم بیلد به Nixpacks (فاز ۲)، مطمئن شویم زنجیرهی
|
|
||||||
# nixpacks (تولید Dockerfile) → kaniko (build واقعی + نصب وابستگیها)
|
|
||||||
# پشت شبکهی ایران کار میکند و کشف کنیم چه mirror/proxy لازم است.
|
|
||||||
#
|
|
||||||
# چرا این ساختار: `nixpacks build --out` فقط Dockerfile میسازد و دانلودی ندارد؛
|
|
||||||
# دانلود سنگین (nixpkgs + npm/go modules) داخل مرحلهی Docker build اتفاق میافتد.
|
|
||||||
# پس برای تست واقعی شبکه باید kaniko همان Dockerfile تولیدی را build کند.
|
|
||||||
# با --no-push نیازی به رجیستری/کردنشال نیست — فقط build تست میشود.
|
|
||||||
#
|
|
||||||
# اجرا:
|
|
||||||
# kubectl apply -f nixpacks-spike.yaml
|
|
||||||
# kubectl -n cloudhost-builds logs -f job/nixpacks-spike-node
|
|
||||||
# kubectl -n cloudhost-builds logs -f job/nixpacks-spike-go
|
|
||||||
# # بعد از اتمام:
|
|
||||||
# kubectl -n cloudhost-builds delete -f nixpacks-spike.yaml
|
|
||||||
#
|
|
||||||
# اگر kaniko سرِ `RUN ... npm install` یا fetch nixpkgs گیر کرد → شبکهی ایران
|
|
||||||
# مانع است؛ env های mirror را (بخش «نکات mirror» پایین فایل) فعال/تنظیم کنید و
|
|
||||||
# دوباره اجرا کنید. نتیجه را برای تصمیم فاز ۲ مستند کنید.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: nixpacks-spike-node-src
|
|
||||||
namespace: cloudhost-builds
|
|
||||||
data:
|
|
||||||
package.json: |
|
|
||||||
{
|
|
||||||
"name": "nixpacks-spike",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": { "start": "node index.js" },
|
|
||||||
"dependencies": { "express": "^4.18.2" }
|
|
||||||
}
|
|
||||||
index.js: |
|
|
||||||
const express = require('express');
|
|
||||||
const app = express();
|
|
||||||
app.get('/', (_req, res) => res.send('nixpacks spike ok'));
|
|
||||||
app.listen(process.env.PORT || 3000, () => console.log('up'));
|
|
||||||
---
|
|
||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: nixpacks-spike-node
|
|
||||||
namespace: cloudhost-builds
|
|
||||||
spec:
|
|
||||||
backoffLimit: 0
|
|
||||||
ttlSecondsAfterFinished: 1800
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
restartPolicy: Never
|
|
||||||
volumes:
|
|
||||||
- name: workspace
|
|
||||||
emptyDir: {}
|
|
||||||
- name: src
|
|
||||||
configMap:
|
|
||||||
name: nixpacks-spike-node-src
|
|
||||||
initContainers:
|
|
||||||
# 1) staging سورس نمونه از ConfigMap به workspace
|
|
||||||
- name: stage-source
|
|
||||||
image: alpine:3.19
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
mkdir -p /workspace/source
|
|
||||||
cp /src/package.json /workspace/source/package.json
|
|
||||||
cp /src/index.js /workspace/source/index.js
|
|
||||||
echo ">>> staged source:" && ls -la /workspace/source
|
|
||||||
volumeMounts:
|
|
||||||
- { name: workspace, mountPath: /workspace }
|
|
||||||
- { name: src, mountPath: /src }
|
|
||||||
# 2) Nixpacks: تولید Dockerfile در /workspace/source/.nixpacks/Dockerfile
|
|
||||||
- name: nixpacks-plan
|
|
||||||
image: ghcr.io/railwayapp/nixpacks:latest
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
# نگاشت همان تنظیماتی که فاز ۲ پاس میدهد (نسخهی Node و PORT)
|
|
||||||
env:
|
|
||||||
- { name: NIXPACKS_NODE_VERSION, value: "20" }
|
|
||||||
# - { name: NPM_CONFIG_REGISTRY, value: "https://registry.npmmirror.com" } # ← در صورت نیاز
|
|
||||||
command:
|
|
||||||
- nixpacks
|
|
||||||
- build
|
|
||||||
- /workspace/source
|
|
||||||
- --out
|
|
||||||
- /workspace/source
|
|
||||||
volumeMounts:
|
|
||||||
- { name: workspace, mountPath: /workspace }
|
|
||||||
containers:
|
|
||||||
# 3) Kaniko: build واقعی Dockerfile تولیدی (تست دانلود وابستگیها). بدون push.
|
|
||||||
- name: kaniko
|
|
||||||
image: gcr.io/kaniko-project/executor:v1.23.2
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- --dockerfile=/workspace/source/.nixpacks/Dockerfile
|
|
||||||
- --context=dir:///workspace/source
|
|
||||||
- --no-push
|
|
||||||
- --verbosity=info
|
|
||||||
volumeMounts:
|
|
||||||
- { name: workspace, mountPath: /workspace }
|
|
||||||
resources:
|
|
||||||
requests: { cpu: "500m", memory: "1Gi" }
|
|
||||||
limits: { cpu: "2", memory: "4Gi" }
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: nixpacks-spike-go-src
|
|
||||||
namespace: cloudhost-builds
|
|
||||||
data:
|
|
||||||
go.mod: |
|
|
||||||
module nixpacksspike
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
main.go: |
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
fmt.Fprintln(w, "nixpacks spike ok")
|
|
||||||
})
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if port == "" {
|
|
||||||
port = "8080"
|
|
||||||
}
|
|
||||||
http.ListenAndServe(":"+port, nil)
|
|
||||||
}
|
|
||||||
---
|
|
||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: nixpacks-spike-go
|
|
||||||
namespace: cloudhost-builds
|
|
||||||
spec:
|
|
||||||
backoffLimit: 0
|
|
||||||
ttlSecondsAfterFinished: 1800
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
restartPolicy: Never
|
|
||||||
volumes:
|
|
||||||
- name: workspace
|
|
||||||
emptyDir: {}
|
|
||||||
- name: src
|
|
||||||
configMap:
|
|
||||||
name: nixpacks-spike-go-src
|
|
||||||
initContainers:
|
|
||||||
- name: stage-source
|
|
||||||
image: alpine:3.19
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
mkdir -p /workspace/source
|
|
||||||
cp /src/go.mod /workspace/source/go.mod
|
|
||||||
cp /src/main.go /workspace/source/main.go
|
|
||||||
echo ">>> staged source:" && ls -la /workspace/source
|
|
||||||
volumeMounts:
|
|
||||||
- { name: workspace, mountPath: /workspace }
|
|
||||||
- { name: src, mountPath: /src }
|
|
||||||
- name: nixpacks-plan
|
|
||||||
image: ghcr.io/railwayapp/nixpacks:latest
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
env:
|
|
||||||
# - { name: GOPROXY, value: "https://goproxy.cn,direct" } # ← در صورت نیاز (mirror چین)
|
|
||||||
command:
|
|
||||||
- nixpacks
|
|
||||||
- build
|
|
||||||
- /workspace/source
|
|
||||||
- --out
|
|
||||||
- /workspace/source
|
|
||||||
volumeMounts:
|
|
||||||
- { name: workspace, mountPath: /workspace }
|
|
||||||
containers:
|
|
||||||
- name: kaniko
|
|
||||||
image: gcr.io/kaniko-project/executor:v1.23.2
|
|
||||||
imagePullPolicy: IfNotPresent
|
|
||||||
args:
|
|
||||||
- --dockerfile=/workspace/source/.nixpacks/Dockerfile
|
|
||||||
- --context=dir:///workspace/source
|
|
||||||
- --no-push
|
|
||||||
- --verbosity=info
|
|
||||||
volumeMounts:
|
|
||||||
- { name: workspace, mountPath: /workspace }
|
|
||||||
resources:
|
|
||||||
requests: { cpu: "500m", memory: "1Gi" }
|
|
||||||
limits: { cpu: "2", memory: "4Gi" }
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# نکات mirror (اگر build گیر کرد، uncomment/تنظیم و دوباره اجرا کنید):
|
|
||||||
# • npm: NPM_CONFIG_REGISTRY=https://registry.npmmirror.com (روی container kaniko
|
|
||||||
# اثر ندارد چون Dockerfile تولیدی است؛ بهتر است در فاز ۲ بهصورت ARG/ENV
|
|
||||||
# داخل مرحلهی نصب تزریق شود — اینجا فقط برای nixpacks-plan گذاشته شده.)
|
|
||||||
# • nix: اگر دانلود nixpkgs (https://github.com/NixOS/...) شکست خورد، احتمال نیاز به
|
|
||||||
# HTTP(S)_PROXY روی container kaniko یا آینهسازی nixpkgs. در لاگ kaniko دنبال
|
|
||||||
# خطوط fetch tarball بگردید.
|
|
||||||
# • go: GOPROXY=https://goproxy.cn,direct یا proxy داخلی.
|
|
||||||
# • اگر pull از ghcr.io/gcr.io خود مشکل داشت → image ها را به رجیستری داخلی mirror کنید
|
|
||||||
# (همان الگوی LOGGING_*_IMAGE در configuration.ts).
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Generated
+485
-358
File diff suppressed because it is too large
Load Diff
+16
-6
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudhost-backend",
|
"name": "abrban-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "CloudHost PaaS Backend API",
|
"description": "CloudHost PaaS Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -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,9 +20,11 @@
|
|||||||
"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": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1077.0",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
"@nestjs/bull": "^11.0.4",
|
"@nestjs/bull": "^11.0.4",
|
||||||
"@nestjs/common": "^11.1.24",
|
"@nestjs/common": "^11.1.24",
|
||||||
@@ -30,6 +34,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,8 +42,8 @@
|
|||||||
"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",
|
||||||
"minio": "^8.0.7",
|
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -46,7 +51,8 @@
|
|||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"typeorm": "^1.0.0",
|
"typeorm": "^1.0.0",
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0",
|
||||||
|
"yauzl": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.23",
|
"@nestjs/cli": "^11.0.23",
|
||||||
@@ -59,6 +65,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@types/yauzl": "^3.4.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||||
"@typescript-eslint/parser": "^8.61.0",
|
"@typescript-eslint/parser": "^8.61.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
@@ -84,6 +91,9 @@
|
|||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node",
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/test-setup.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copy SQL migrations from backend/migrations/ into the Helm chart ConfigMap source.
|
||||||
|
* Run after adding or editing migration files: npm run sync:migrations
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourceDir = path.resolve(__dirname, '../migrations');
|
||||||
|
const targetDir = path.resolve(__dirname, '../helm/cloudhost-platform/migrations');
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
console.error(`Source not found: ${sourceDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.sql')).sort();
|
||||||
|
for (const file of files) {
|
||||||
|
fs.copyFileSync(path.join(sourceDir, file), path.join(targetDir, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale SQL files no longer in source
|
||||||
|
for (const existing of fs.readdirSync(targetDir)) {
|
||||||
|
if (existing.endsWith('.sql') && !files.includes(existing)) {
|
||||||
|
fs.unlinkSync(path.join(targetDir, existing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Synced ${files.length} migration(s) to ${targetDir}`);
|
||||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { ApplicationsModule } from './applications/applications.module';
|
import { ApplicationsModule } from './applications/applications.module';
|
||||||
@@ -15,8 +17,8 @@ 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 { RedisModule } from './common/redis/redis.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { StorageModule } from './common/storage/storage.module';
|
import { StorageModule } from './storage/storage.module';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -56,13 +58,16 @@ import configuration from './config/configuration';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Shared Redis client (build state across replicas)
|
ThrottlerModule.forRoot([
|
||||||
RedisModule,
|
{
|
||||||
|
name: 'default',
|
||||||
// Shared MinIO storage (application source archives)
|
ttl: 60_000,
|
||||||
StorageModule,
|
limit: 120,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
// Feature modules
|
// Feature modules
|
||||||
|
StorageModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ApplicationsModule,
|
ApplicationsModule,
|
||||||
@@ -76,6 +81,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 {}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
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, ApiBadRequestResponse } 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,7 +68,20 @@ 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)' })
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'Runtime mismatch between selected app type and archive contents',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Selected runtime "nodejs" does not match the uploaded source (detected "go").',
|
||||||
|
configured: 'nodejs',
|
||||||
|
detected: 'go',
|
||||||
|
signals: ['go.mod'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10 GiB max application archive
|
limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10 GiB max application archive
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ import {
|
|||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { ensureAppUrlEnv } from './app-url.util';
|
import { ensureAppUrlEnv } from './app-url.util';
|
||||||
import { normalizeCreateApplicationDto } from './managed-service.util';
|
import { normalizeCreateApplicationDto } from './managed-service.util';
|
||||||
import { StorageService } from '../common/storage/storage.service';
|
import {
|
||||||
|
assertRuntimeMatch,
|
||||||
|
detectRuntimeFromArchive,
|
||||||
|
} from '../build/runtime-detector';
|
||||||
|
import { SourceStorageService } from '../storage/source-storage.service';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApplicationsService {
|
export class ApplicationsService {
|
||||||
@@ -28,7 +33,7 @@ export class ApplicationsService {
|
|||||||
private appsRepository: Repository<Application>,
|
private appsRepository: Repository<Application>,
|
||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private storageService: StorageService,
|
private sourceStorage: SourceStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private toDnsLabel(value: string): string {
|
private toDnsLabel(value: string): string {
|
||||||
@@ -206,20 +211,13 @@ export class ApplicationsService {
|
|||||||
async delete(id: string, userId: string): Promise<Application> {
|
async delete(id: string, userId: string): Promise<Application> {
|
||||||
const app = await this.findOne(id, userId);
|
const app = await this.findOne(id, userId);
|
||||||
|
|
||||||
// Delete the uploaded source archive from object storage.
|
// Delete uploaded source files
|
||||||
if (app.codePath) {
|
if (app.codePath) {
|
||||||
await this.storageService.removeSource(app.codePath);
|
try {
|
||||||
}
|
await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath);
|
||||||
// Remove any legacy on-disk dump/source dir (db dumps are still stored locally).
|
} catch (e: any) {
|
||||||
try {
|
this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`);
|
||||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
|
||||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
|
||||||
if (fs.existsSync(appDir)) {
|
|
||||||
fs.rmSync(appDir, { recursive: true, force: true });
|
|
||||||
this.logger.log(`Deleted upload directory: ${appDir}`);
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.appsRepository.remove(app);
|
await this.appsRepository.remove(app);
|
||||||
@@ -265,15 +263,38 @@ export class ApplicationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = await this.findOne(id, userId);
|
const app = await this.findOne(id, userId);
|
||||||
|
const tempPath = path.join(os.tmpdir(), `upload-${app.id}-${Date.now()}.zip`);
|
||||||
|
fs.writeFileSync(tempPath, file.buffer);
|
||||||
|
|
||||||
// Stream the archive to MinIO; codePath stores the object key (build pods
|
try {
|
||||||
// pull it via a presigned URL — no local disk, no PVC, no kubectl cp).
|
const detected = await detectRuntimeFromArchive(tempPath);
|
||||||
const key = await this.storageService.putSource(app.userId, app.id, file.buffer);
|
assertRuntimeMatch(app.runtime, detected);
|
||||||
app.codePath = key;
|
|
||||||
const saved = await this.appsRepository.save(app);
|
|
||||||
|
|
||||||
this.logger.log(`Uploaded code for ${app.name} → ${key} (${(file.size / 1024).toFixed(1)} KB)`);
|
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer);
|
||||||
return saved;
|
app.codePath = storedPath;
|
||||||
|
const saved = await this.appsRepository.save(app);
|
||||||
|
|
||||||
|
if (detected.confidence === 'low') {
|
||||||
|
Object.assign(saved, {
|
||||||
|
runtimeWarning:
|
||||||
|
'Could not determine the project type from the archive with high confidence. Build may fail if the selected runtime is wrong.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Uploaded code for ${app.name} → ${storedPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||||
|
return saved;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await this.sourceStorage.deleteSource(app.userId, app.id);
|
||||||
|
} catch {
|
||||||
|
// ignore rollback errors
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadDbDump(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
|
async uploadDbDump(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -7,6 +8,7 @@ import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto';
|
|||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
|
@Throttle({ default: { limit: 20, ttl: 60_000 } })
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { BillingOpsService } from './billing-ops.service';
|
||||||
|
import { assertStubGatewayAllowed } from './payment-gateway.util';
|
||||||
|
import {
|
||||||
|
ChargeWalletDto,
|
||||||
|
InitiateInvoicePaymentDto,
|
||||||
|
VerifyInvoiceGatewayDto,
|
||||||
|
UpdateInvoiceStatusDto,
|
||||||
|
} from './dto/billing.dto';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { UserRole, InvoiceStatus, PaymentMethod } from '../common/enums';
|
||||||
|
|
||||||
|
@ApiTags('Billing')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('billing')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class BillingInvoicesController {
|
||||||
|
constructor(
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
private readonly billingOpsService: BillingOpsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Invoices ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('invoices')
|
||||||
|
@ApiOperation({ summary: 'List my invoices' })
|
||||||
|
async listMyInvoices(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('status') status?: InvoiceStatus,
|
||||||
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
return this.billingService.listInvoices(req.user, {
|
||||||
|
status,
|
||||||
|
applicationId,
|
||||||
|
limit: limit ? parseInt(limit, 10) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('invoices/:id')
|
||||||
|
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
|
||||||
|
async getInvoice(@Request() req: any, @Param('id') id: string) {
|
||||||
|
return this.billingService.getInvoiceForUser(id, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('invoices/:id/pay/mixed')
|
||||||
|
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
|
||||||
|
async initiateInvoiceMixed(
|
||||||
|
@Request() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: InitiateInvoicePaymentDto,
|
||||||
|
) {
|
||||||
|
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
|
||||||
|
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
|
||||||
|
return { ...result, effect };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('invoices/:id/gateway/verify')
|
||||||
|
@ApiOperation({ summary: 'Verify invoice gateway payment' })
|
||||||
|
async verifyInvoiceGateway(
|
||||||
|
@Request() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: VerifyInvoiceGatewayDto,
|
||||||
|
) {
|
||||||
|
assertStubGatewayAllowed();
|
||||||
|
const result = await this.billingService.verifyInvoiceGatewayPayment(
|
||||||
|
id,
|
||||||
|
req.user,
|
||||||
|
dto.trackingCode,
|
||||||
|
dto.amount,
|
||||||
|
);
|
||||||
|
const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice);
|
||||||
|
return { ...result, effect };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Invoice Admin ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('admin/invoices')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'List all invoices (Admin)' })
|
||||||
|
async listAdminInvoices(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('status') status?: InvoiceStatus,
|
||||||
|
@Query('userId') userId?: string,
|
||||||
|
@Query('applicationId') applicationId?: string,
|
||||||
|
@Query('paymentMethod') paymentMethod?: PaymentMethod,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
return this.billingService.listInvoices(req.user, {
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
applicationId,
|
||||||
|
paymentMethod,
|
||||||
|
search,
|
||||||
|
limit: limit ? parseInt(limit, 10) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/invoices/:id')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Get invoice details (Admin)' })
|
||||||
|
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
|
||||||
|
return this.billingService.getInvoiceForUser(id, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/invoices/:id/status')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
|
||||||
|
async updateAdminInvoiceStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateInvoiceStatusDto,
|
||||||
|
) {
|
||||||
|
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('admin/wallets')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'List all wallets (Admin)' })
|
||||||
|
async getAllWallets() {
|
||||||
|
return this.billingService.getAllWallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/wallets/:userId/charge')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
|
||||||
|
async adminChargeWallet(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: ChargeWalletDto,
|
||||||
|
) {
|
||||||
|
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||||
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
|
import { UpgradeResourcesDto } from './dto/billing.dto';
|
||||||
|
import {
|
||||||
|
BillingCycle,
|
||||||
|
InvoiceStatus,
|
||||||
|
ProductType,
|
||||||
|
DatabaseType,
|
||||||
|
UserRole,
|
||||||
|
} from '../common/enums';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingOpsService {
|
||||||
|
constructor(
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
@Inject(forwardRef(() => AppLifecycleService))
|
||||||
|
private readonly lifecycleService: AppLifecycleService,
|
||||||
|
@Inject(forwardRef(() => ApplicationsService))
|
||||||
|
private readonly applicationsService: ApplicationsService,
|
||||||
|
@Inject(forwardRef(() => KubernetesService))
|
||||||
|
private readonly kubernetesService: KubernetesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async completePaidInvoiceEffect(invoice: any) {
|
||||||
|
if (invoice.status !== InvoiceStatus.PAID) return null;
|
||||||
|
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
|
||||||
|
|
||||||
|
const action = invoice.metadata?.action;
|
||||||
|
if (!action || !invoice.applicationId) return null;
|
||||||
|
|
||||||
|
if (action === 'renew' || action === 'activate') {
|
||||||
|
const cycle = invoice.metadata?.cycle as BillingCycle;
|
||||||
|
if (!Object.values(BillingCycle).includes(cycle)) return null;
|
||||||
|
|
||||||
|
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
|
||||||
|
const result = {
|
||||||
|
action,
|
||||||
|
application: {
|
||||||
|
id: activated.id,
|
||||||
|
name: activated.name,
|
||||||
|
lifecycleStatus: activated.lifecycleStatus,
|
||||||
|
planExpiresAt: activated.planExpiresAt,
|
||||||
|
billingCycle: activated.billingCycle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'upgrade') {
|
||||||
|
const app = await this.applicationsService.findOne(invoice.applicationId);
|
||||||
|
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
|
||||||
|
const updatedApp = await this.applicationsService.update(
|
||||||
|
app.id,
|
||||||
|
app.userId,
|
||||||
|
this.buildUpgradeEntityPatch(app, resources),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
action,
|
||||||
|
application: {
|
||||||
|
id: updatedApp.id,
|
||||||
|
name: updatedApp.name,
|
||||||
|
cpuRequest: updatedApp.cpuRequest,
|
||||||
|
cpuLimit: updatedApp.cpuLimit,
|
||||||
|
memoryRequest: updatedApp.memoryRequest,
|
||||||
|
memoryLimit: updatedApp.memoryLimit,
|
||||||
|
replicas: updatedApp.replicas,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
||||||
|
const pt = app.productType ?? ProductType.APPLICATION;
|
||||||
|
|
||||||
|
if (dto.redisResources) {
|
||||||
|
return {
|
||||||
|
optionalServiceResources: {
|
||||||
|
...app.optionalServiceResources,
|
||||||
|
redis: {
|
||||||
|
...app.optionalServiceResources?.redis,
|
||||||
|
...dto.redisResources,
|
||||||
|
storageGi:
|
||||||
|
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.rabbitmqResources) {
|
||||||
|
return {
|
||||||
|
optionalServiceResources: {
|
||||||
|
...app.optionalServiceResources,
|
||||||
|
rabbitmq: {
|
||||||
|
...app.optionalServiceResources?.rabbitmq,
|
||||||
|
...dto.rabbitmqResources,
|
||||||
|
storageGi:
|
||||||
|
dto.rabbitmqResources.storageGi ??
|
||||||
|
app.optionalServiceResources?.rabbitmq?.storageGi ??
|
||||||
|
2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||||
|
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||||
|
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||||
|
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||||
|
replicas: dto.replicas ?? app.replicas,
|
||||||
|
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||||
|
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyUpgradeToKubernetes(
|
||||||
|
app: Application,
|
||||||
|
dto: UpgradeResourcesDto,
|
||||||
|
previous: Application,
|
||||||
|
): Promise<void> {
|
||||||
|
const pt = app.productType ?? ProductType.APPLICATION;
|
||||||
|
|
||||||
|
if (pt === ProductType.MANAGED_DATABASE) {
|
||||||
|
await this.kubernetesService.updateResources(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
cpuRequest: dto.cpuRequest,
|
||||||
|
cpuLimit: dto.cpuLimit,
|
||||||
|
memoryRequest: dto.memoryRequest,
|
||||||
|
memoryLimit: dto.memoryLimit,
|
||||||
|
},
|
||||||
|
'database',
|
||||||
|
);
|
||||||
|
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
|
||||||
|
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||||
|
if (!resize.success) {
|
||||||
|
throw new BadRequestException(resize.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pt === ProductType.MANAGED_REDIS) {
|
||||||
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pt === ProductType.MANAGED_RABBITMQ) {
|
||||||
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.redisResources && app.enableRedis) {
|
||||||
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.rabbitmqResources && app.enableRabbitmq) {
|
||||||
|
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchesAppWorkload =
|
||||||
|
dto.cpuRequest !== undefined ||
|
||||||
|
dto.cpuLimit !== undefined ||
|
||||||
|
dto.memoryRequest !== undefined ||
|
||||||
|
dto.memoryLimit !== undefined ||
|
||||||
|
dto.replicas !== undefined;
|
||||||
|
|
||||||
|
if (touchesAppWorkload) {
|
||||||
|
await this.kubernetesService.updateResources(app, {
|
||||||
|
cpuRequest: dto.cpuRequest,
|
||||||
|
cpuLimit: dto.cpuLimit,
|
||||||
|
memoryRequest: dto.memoryRequest,
|
||||||
|
memoryLimit: dto.memoryLimit,
|
||||||
|
replicas: dto.replicas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
||||||
|
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.dbStorageSize &&
|
||||||
|
dto.dbStorageSize !== previous.dbStorageSize &&
|
||||||
|
previous.databaseType &&
|
||||||
|
previous.databaseType !== DatabaseType.NONE
|
||||||
|
) {
|
||||||
|
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||||
|
if (!resize.success) {
|
||||||
|
throw new BadRequestException(resize.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyOptionalServiceUpgrade(
|
||||||
|
app: Application,
|
||||||
|
dto: UpgradeResourcesDto,
|
||||||
|
previous: Application,
|
||||||
|
service: 'redis' | 'rabbitmq',
|
||||||
|
): Promise<void> {
|
||||||
|
const res = app.optionalServiceResources?.[service];
|
||||||
|
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
|
||||||
|
if (res) {
|
||||||
|
await this.kubernetesService.updateResources(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
cpuRequest: res.cpuRequest,
|
||||||
|
cpuLimit: res.cpuLimit,
|
||||||
|
memoryRequest: res.memoryRequest,
|
||||||
|
memoryLimit: res.memoryLimit,
|
||||||
|
},
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prevGi =
|
||||||
|
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
|
||||||
|
const nextGi = dtoRes?.storageGi;
|
||||||
|
if (nextGi != null && nextGi > prevGi) {
|
||||||
|
const resize =
|
||||||
|
service === 'redis'
|
||||||
|
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
|
||||||
|
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
|
||||||
|
if (!resize.success) {
|
||||||
|
throw new BadRequestException(resize.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppWithAccess(user: any, applicationId: string) {
|
||||||
|
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||||
|
|
||||||
|
if (isAdminOrSales) {
|
||||||
|
return this.applicationsService.findOne(applicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.applicationsService.findOne(applicationId, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { assertStubGatewayAllowed } from './payment-gateway.util';
|
||||||
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||||
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
|
import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { BillingCycle, InvoiceReason } from '../common/enums';
|
||||||
|
|
||||||
|
@ApiTags('Billing')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('billing')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class BillingWalletController {
|
||||||
|
constructor(
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
@Inject(forwardRef(() => AppLifecycleService))
|
||||||
|
private readonly lifecycleService: AppLifecycleService,
|
||||||
|
@Inject(forwardRef(() => ApplicationsService))
|
||||||
|
private readonly applicationsService: ApplicationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('wallet')
|
||||||
|
@ApiOperation({ summary: 'Get my wallet balance' })
|
||||||
|
async getBalance(@Request() req: any) {
|
||||||
|
return this.billingService.getBalance(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('wallet/charge')
|
||||||
|
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
|
||||||
|
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
||||||
|
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('wallet/transactions')
|
||||||
|
@ApiOperation({ summary: 'Get my wallet transactions' })
|
||||||
|
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
|
||||||
|
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('resource-credits')
|
||||||
|
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
|
||||||
|
async getResourceCredits(@Request() req: any) {
|
||||||
|
const credits = await this.billingService.getActiveCredits(req.user.id);
|
||||||
|
return credits.map((c) => this.billingService.formatCreditForApi(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('wallet/pay/:applicationId')
|
||||||
|
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
||||||
|
async payForApplication(
|
||||||
|
@Request() req: any,
|
||||||
|
@Param('applicationId') applicationId: string,
|
||||||
|
@Body() body: PayApplicationDto,
|
||||||
|
) {
|
||||||
|
const cycle = body.cycle as BillingCycle;
|
||||||
|
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||||
|
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, req.user.id);
|
||||||
|
|
||||||
|
const payment = await this.billingService.resolveAppPayment(
|
||||||
|
req.user.id,
|
||||||
|
app,
|
||||||
|
cycle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const coupon = await this.billingService.resolveCoupon(
|
||||||
|
req.user.id,
|
||||||
|
body.couponCode,
|
||||||
|
await this.billingService.getAppChargeBreakdown(app),
|
||||||
|
cycle,
|
||||||
|
payment.amountDue,
|
||||||
|
);
|
||||||
|
|
||||||
|
let invoice = null;
|
||||||
|
if (payment.amountDue > 0) {
|
||||||
|
invoice = await this.billingService.createInvoice({
|
||||||
|
userId: req.user.id,
|
||||||
|
applicationId: app.id,
|
||||||
|
reason: InvoiceReason.DEPLOY,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
label: `Application payment: ${app.name}`,
|
||||||
|
description: `Billing cycle: ${cycle}`,
|
||||||
|
amount: payment.amountDue,
|
||||||
|
metadata: {
|
||||||
|
cycle,
|
||||||
|
waivedAmount: payment.waivedAmount,
|
||||||
|
creditApplied: payment.creditId || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
action: 'activate',
|
||||||
|
cycle,
|
||||||
|
},
|
||||||
|
discount: coupon ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = null;
|
||||||
|
if (invoice) {
|
||||||
|
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||||
|
tx = paid.transaction;
|
||||||
|
invoice = paid.invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activated = await this.lifecycleService.activateApp(
|
||||||
|
applicationId,
|
||||||
|
cycle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transaction: tx,
|
||||||
|
invoice,
|
||||||
|
creditApplied: payment.creditId || null,
|
||||||
|
waivedAmount: payment.waivedAmount,
|
||||||
|
discountAmount: coupon?.amount ?? 0,
|
||||||
|
discountCode: coupon?.code ?? null,
|
||||||
|
paidAmount: invoice ? Number(invoice.total) : 0,
|
||||||
|
application: {
|
||||||
|
id: activated.id,
|
||||||
|
name: activated.name,
|
||||||
|
lifecycleStatus: activated.lifecycleStatus,
|
||||||
|
planExpiresAt: activated.planExpiresAt,
|
||||||
|
},
|
||||||
|
message: payment.waivedAmount > 0
|
||||||
|
? payment.amountDue > 0
|
||||||
|
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
|
||||||
|
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
|
||||||
|
: payment.amountDue > 0
|
||||||
|
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
|
||||||
|
: `Application "${activated.name}" activated`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Payment Gateway ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Post('gateway/initiate')
|
||||||
|
@ApiOperation({ summary: 'Initiate a payment gateway transaction' })
|
||||||
|
async initiateGateway(
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() body: { amount: number; description?: string; callbackUrl: string },
|
||||||
|
) {
|
||||||
|
assertStubGatewayAllowed();
|
||||||
|
const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
trackingCode,
|
||||||
|
gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`,
|
||||||
|
message: 'Redirect user to gatewayUrl to complete payment',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('gateway/verify')
|
||||||
|
@ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' })
|
||||||
|
async verifyGateway(
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() body: { trackingCode: string; amount: number },
|
||||||
|
) {
|
||||||
|
assertStubGatewayAllowed();
|
||||||
|
await this.billingService.chargeWallet(
|
||||||
|
req.user.id,
|
||||||
|
body.amount,
|
||||||
|
`Payment gateway: ${body.trackingCode}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Payment verified and wallet charged',
|
||||||
|
trackingCode: body.trackingCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,35 +3,27 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Inject,
|
Inject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
|
import { BillingOpsService } from './billing-ops.service';
|
||||||
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||||
import { ApplicationsService } from '../applications/applications.service';
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
|
||||||
import {
|
import {
|
||||||
ChargeWalletDto,
|
|
||||||
CalculateCostDto,
|
CalculateCostDto,
|
||||||
CalculateDeployCostDto,
|
CalculateDeployCostDto,
|
||||||
SetOptionalServicesPricingDto,
|
SetOptionalServicesPricingDto,
|
||||||
RenewApplicationDto,
|
RenewApplicationDto,
|
||||||
UpgradeResourcesDto,
|
UpgradeResourcesDto,
|
||||||
CalculateUpgradeCostDto,
|
CalculateUpgradeCostDto,
|
||||||
InitiateInvoicePaymentDto,
|
|
||||||
VerifyInvoiceGatewayDto,
|
|
||||||
UpdateInvoiceStatusDto,
|
|
||||||
PayApplicationDto,
|
|
||||||
} from './dto/billing.dto';
|
} from './dto/billing.dto';
|
||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
@@ -41,12 +33,7 @@ import {
|
|||||||
BillingCycle,
|
BillingCycle,
|
||||||
AppLifecycleStatus,
|
AppLifecycleStatus,
|
||||||
InvoiceReason,
|
InvoiceReason,
|
||||||
InvoiceStatus,
|
|
||||||
PaymentMethod,
|
|
||||||
ProductType,
|
|
||||||
DatabaseType,
|
|
||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
|
||||||
|
|
||||||
@ApiTags('Billing')
|
@ApiTags('Billing')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -55,12 +42,11 @@ import { Application } from '../applications/entities/application.entity';
|
|||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
|
private readonly billingOpsService: BillingOpsService,
|
||||||
@Inject(forwardRef(() => AppLifecycleService))
|
@Inject(forwardRef(() => AppLifecycleService))
|
||||||
private readonly lifecycleService: AppLifecycleService,
|
private readonly lifecycleService: AppLifecycleService,
|
||||||
@Inject(forwardRef(() => ApplicationsService))
|
@Inject(forwardRef(() => ApplicationsService))
|
||||||
private readonly applicationsService: ApplicationsService,
|
private readonly applicationsService: ApplicationsService,
|
||||||
@Inject(forwardRef(() => KubernetesService))
|
|
||||||
private readonly kubernetesService: KubernetesService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||||
@@ -98,6 +84,29 @@ export class BillingController {
|
|||||||
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle, dto.couponCode);
|
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle, dto.couponCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Global discount (platform-wide) ───────────────────────────
|
||||||
|
|
||||||
|
@Get('settings/global-discount')
|
||||||
|
@ApiOperation({ summary: 'Get the platform-wide discount percentage' })
|
||||||
|
async getGlobalDiscount() {
|
||||||
|
return this.billingService.getGlobalDiscount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('settings/global-discount')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Set the platform-wide discount percentage (Admin)' })
|
||||||
|
async setGlobalDiscount(@Body() body: { percentOff: number }) {
|
||||||
|
if (
|
||||||
|
body.percentOff === undefined ||
|
||||||
|
typeof body.percentOff !== 'number' ||
|
||||||
|
body.percentOff < 0 ||
|
||||||
|
body.percentOff > 100
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('percentOff must be a number between 0 and 100');
|
||||||
|
}
|
||||||
|
return this.billingService.setGlobalDiscount(body.percentOff);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Custom Domain Pricing ─────────────────────────────────────
|
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||||
|
|
||||||
@Get('settings/custom-domain-price')
|
@Get('settings/custom-domain-price')
|
||||||
@@ -129,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')
|
||||||
@@ -405,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,
|
||||||
@@ -425,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
|
||||||
@@ -467,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
|
||||||
@@ -510,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 {
|
||||||
@@ -544,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,
|
||||||
@@ -560,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
|
||||||
@@ -617,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 {
|
||||||
@@ -652,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.`,
|
||||||
@@ -704,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
|
||||||
@@ -761,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}`);
|
||||||
}
|
}
|
||||||
@@ -790,238 +522,4 @@ export class BillingController {
|
|||||||
: 'Resources updated (downgrade or no cost change).',
|
: 'Resources updated (downgrade or no cost change).',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper Methods ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async completePaidInvoiceEffect(invoice: any) {
|
|
||||||
if (invoice.status !== InvoiceStatus.PAID) return null;
|
|
||||||
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
|
|
||||||
|
|
||||||
const action = invoice.metadata?.action;
|
|
||||||
if (!action || !invoice.applicationId) return null;
|
|
||||||
|
|
||||||
if (action === 'renew' || action === 'activate') {
|
|
||||||
const cycle = invoice.metadata?.cycle as BillingCycle;
|
|
||||||
if (!Object.values(BillingCycle).includes(cycle)) return null;
|
|
||||||
|
|
||||||
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
|
|
||||||
const result = {
|
|
||||||
action,
|
|
||||||
application: {
|
|
||||||
id: activated.id,
|
|
||||||
name: activated.name,
|
|
||||||
lifecycleStatus: activated.lifecycleStatus,
|
|
||||||
planExpiresAt: activated.planExpiresAt,
|
|
||||||
billingCycle: activated.billingCycle,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'upgrade') {
|
|
||||||
const app = await this.applicationsService.findOne(invoice.applicationId);
|
|
||||||
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
|
|
||||||
const updatedApp = await this.applicationsService.update(
|
|
||||||
app.id,
|
|
||||||
app.userId,
|
|
||||||
this.buildUpgradeEntityPatch(app, resources),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
action,
|
|
||||||
application: {
|
|
||||||
id: updatedApp.id,
|
|
||||||
name: updatedApp.name,
|
|
||||||
cpuRequest: updatedApp.cpuRequest,
|
|
||||||
cpuLimit: updatedApp.cpuLimit,
|
|
||||||
memoryRequest: updatedApp.memoryRequest,
|
|
||||||
memoryLimit: updatedApp.memoryLimit,
|
|
||||||
replicas: updatedApp.replicas,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
|
||||||
const pt = app.productType ?? ProductType.APPLICATION;
|
|
||||||
|
|
||||||
if (dto.redisResources) {
|
|
||||||
return {
|
|
||||||
optionalServiceResources: {
|
|
||||||
...app.optionalServiceResources,
|
|
||||||
redis: {
|
|
||||||
...app.optionalServiceResources?.redis,
|
|
||||||
...dto.redisResources,
|
|
||||||
storageGi:
|
|
||||||
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.rabbitmqResources) {
|
|
||||||
return {
|
|
||||||
optionalServiceResources: {
|
|
||||||
...app.optionalServiceResources,
|
|
||||||
rabbitmq: {
|
|
||||||
...app.optionalServiceResources?.rabbitmq,
|
|
||||||
...dto.rabbitmqResources,
|
|
||||||
storageGi:
|
|
||||||
dto.rabbitmqResources.storageGi ??
|
|
||||||
app.optionalServiceResources?.rabbitmq?.storageGi ??
|
|
||||||
2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
|
||||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
|
||||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
|
||||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
|
||||||
replicas: dto.replicas ?? app.replicas,
|
|
||||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
|
||||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyUpgradeToKubernetes(
|
|
||||||
app: Application,
|
|
||||||
dto: UpgradeResourcesDto,
|
|
||||||
previous: Application,
|
|
||||||
): Promise<void> {
|
|
||||||
const pt = app.productType ?? ProductType.APPLICATION;
|
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_DATABASE) {
|
|
||||||
await this.kubernetesService.updateResources(
|
|
||||||
app,
|
|
||||||
{
|
|
||||||
cpuRequest: dto.cpuRequest,
|
|
||||||
cpuLimit: dto.cpuLimit,
|
|
||||||
memoryRequest: dto.memoryRequest,
|
|
||||||
memoryLimit: dto.memoryLimit,
|
|
||||||
},
|
|
||||||
'database',
|
|
||||||
);
|
|
||||||
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
|
|
||||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
|
||||||
if (!resize.success) {
|
|
||||||
throw new BadRequestException(resize.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_REDIS) {
|
|
||||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pt === ProductType.MANAGED_RABBITMQ) {
|
|
||||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.redisResources && app.enableRedis) {
|
|
||||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.rabbitmqResources && app.enableRabbitmq) {
|
|
||||||
await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq');
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchesAppWorkload =
|
|
||||||
dto.cpuRequest !== undefined ||
|
|
||||||
dto.cpuLimit !== undefined ||
|
|
||||||
dto.memoryRequest !== undefined ||
|
|
||||||
dto.memoryLimit !== undefined ||
|
|
||||||
dto.replicas !== undefined;
|
|
||||||
|
|
||||||
if (touchesAppWorkload) {
|
|
||||||
await this.kubernetesService.updateResources(app, {
|
|
||||||
cpuRequest: dto.cpuRequest,
|
|
||||||
cpuLimit: dto.cpuLimit,
|
|
||||||
memoryRequest: dto.memoryRequest,
|
|
||||||
memoryLimit: dto.memoryLimit,
|
|
||||||
replicas: dto.replicas,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
|
||||||
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
dto.dbStorageSize &&
|
|
||||||
dto.dbStorageSize !== previous.dbStorageSize &&
|
|
||||||
previous.databaseType &&
|
|
||||||
previous.databaseType !== DatabaseType.NONE
|
|
||||||
) {
|
|
||||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
|
||||||
if (!resize.success) {
|
|
||||||
throw new BadRequestException(resize.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyOptionalServiceUpgrade(
|
|
||||||
app: Application,
|
|
||||||
dto: UpgradeResourcesDto,
|
|
||||||
previous: Application,
|
|
||||||
service: 'redis' | 'rabbitmq',
|
|
||||||
): Promise<void> {
|
|
||||||
const res = app.optionalServiceResources?.[service];
|
|
||||||
const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources;
|
|
||||||
if (res) {
|
|
||||||
await this.kubernetesService.updateResources(
|
|
||||||
app,
|
|
||||||
{
|
|
||||||
cpuRequest: res.cpuRequest,
|
|
||||||
cpuLimit: res.cpuLimit,
|
|
||||||
memoryRequest: res.memoryRequest,
|
|
||||||
memoryLimit: res.memoryLimit,
|
|
||||||
},
|
|
||||||
service,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const prevGi =
|
|
||||||
previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2);
|
|
||||||
const nextGi = dtoRes?.storageGi;
|
|
||||||
if (nextGi != null && nextGi > prevGi) {
|
|
||||||
const resize =
|
|
||||||
service === 'redis'
|
|
||||||
? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`)
|
|
||||||
: await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`);
|
|
||||||
if (!resize.success) {
|
|
||||||
throw new BadRequestException(resize.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAppWithAccess(user: any, applicationId: string) {
|
|
||||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
|
||||||
|
|
||||||
if (isAdminOrSales) {
|
|
||||||
return this.applicationsService.findOne(applicationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular user - must own the app
|
|
||||||
return this.applicationsService.findOne(applicationId, user.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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 { DiscountController } from './discount.controller';
|
import { DiscountController } from './discount.controller';
|
||||||
import { DiscountService } from './discount.service';
|
import { DiscountService } from './discount.service';
|
||||||
import { PricingCatalogService } from './pricing-catalog.service';
|
import { PricingCatalogService } from './pricing-catalog.service';
|
||||||
@@ -9,6 +13,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
|||||||
import { AddonRate } from './entities/addon-rate.entity';
|
import { AddonRate } from './entities/addon-rate.entity';
|
||||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||||
@@ -34,13 +39,20 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
|||||||
InvoiceLine,
|
InvoiceLine,
|
||||||
Discount,
|
Discount,
|
||||||
DiscountRedemption,
|
DiscountRedemption,
|
||||||
|
PlatformSetting,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => LifecycleModule),
|
forwardRef(() => LifecycleModule),
|
||||||
forwardRef(() => ApplicationsModule),
|
forwardRef(() => ApplicationsModule),
|
||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
],
|
],
|
||||||
controllers: [BillingController, 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 {}
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ export class BillingService {
|
|||||||
return this.pricingCatalog.updateCatalog(dto);
|
return this.pricingCatalog.updateCatalog(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Global (platform-wide) discount ──────────────────────────────
|
||||||
|
|
||||||
|
/** Current platform-wide discount percentage (0–100). */
|
||||||
|
async getGlobalDiscount(): Promise<{ percentOff: number }> {
|
||||||
|
return { percentOff: await this.pricingCatalog.getGlobalDiscountPercent(true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the platform-wide discount percentage (Admin). */
|
||||||
|
async setGlobalDiscount(percentOff: number): Promise<{ percentOff: number }> {
|
||||||
|
return { percentOff: await this.pricingCatalog.setGlobalDiscountPercent(percentOff) };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Cost Calculation ─────────────────────────────────────────────
|
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
|||||||
import { AddonRate } from './entities/addon-rate.entity';
|
import { AddonRate } from './entities/addon-rate.entity';
|
||||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import {
|
import {
|
||||||
AppRuntime,
|
AppRuntime,
|
||||||
BillingCycle,
|
BillingCycle,
|
||||||
@@ -47,6 +48,13 @@ describe('PricingCatalogService', () => {
|
|||||||
create: jest.fn().mockImplementation((x) => x),
|
create: jest.fn().mockImplementation((x) => x),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const settingsRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
|
||||||
|
create: jest.fn().mockImplementation((x) => x),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -56,6 +64,7 @@ describe('PricingCatalogService', () => {
|
|||||||
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
||||||
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
||||||
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
||||||
|
{ provide: getRepositoryToken(PlatformSetting), useValue: settingsRepo },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
|||||||
import { AddonRate } from './entities/addon-rate.entity';
|
import { AddonRate } from './entities/addon-rate.entity';
|
||||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import {
|
import {
|
||||||
AppRuntime,
|
AppRuntime,
|
||||||
BillingCycle,
|
BillingCycle,
|
||||||
@@ -39,12 +40,22 @@ import {
|
|||||||
} from './dto/pricing-catalog.dto';
|
} from './dto/pricing-catalog.dto';
|
||||||
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
|
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
|
||||||
|
|
||||||
|
/** PlatformSetting key holding the platform-wide discount percentage (0–100). */
|
||||||
|
export const GLOBAL_DISCOUNT_SETTING_KEY = 'global_discount_percent';
|
||||||
|
|
||||||
export interface CyclePrices {
|
export interface CyclePrices {
|
||||||
hourly: number;
|
hourly: number;
|
||||||
monthly: number;
|
monthly: number;
|
||||||
yearly: number;
|
yearly: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CostTotals {
|
||||||
|
hourly: number;
|
||||||
|
monthly: number;
|
||||||
|
yearly: number;
|
||||||
|
breakdown: CostBreakdownLine[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingRateRow {
|
export interface PricingRateRow {
|
||||||
resourceType: PricingResourceType;
|
resourceType: PricingResourceType;
|
||||||
hourlyPrice: number;
|
hourlyPrice: number;
|
||||||
@@ -129,10 +140,18 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
|
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
|
||||||
@InjectRepository(OptionalServiceRate)
|
@InjectRepository(OptionalServiceRate)
|
||||||
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
|
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
|
||||||
|
@InjectRepository(PlatformSetting)
|
||||||
|
private readonly settingsRepo: Repository<PlatformSetting>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** In-memory cache of the global discount % (TTL-refreshed; single-replica safe). */
|
||||||
|
private cachedGlobalDiscountPct = 0;
|
||||||
|
private cachedGlobalDiscountAt = 0;
|
||||||
|
private static readonly GLOBAL_DISCOUNT_TTL_MS = 30_000;
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
|
await this.getGlobalDiscountPercent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureDefaults() {
|
async ensureDefaults() {
|
||||||
@@ -337,13 +356,89 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async computeTotalsFromDb(dto: CalculateCostDto) {
|
/** Raw totals straight from the catalog, before any platform-wide discount. */
|
||||||
|
async computeTotalsRawFromDb(dto: CalculateCostDto): Promise<CostTotals> {
|
||||||
const runtime = dto.runtime as AppRuntime;
|
const runtime = dto.runtime as AppRuntime;
|
||||||
const rates = await this.getRatesForRuntime(runtime);
|
const rates = await this.getRatesForRuntime(runtime);
|
||||||
const optional = await this.getOptionalBillingContext();
|
const optional = await this.getOptionalBillingContext();
|
||||||
return this.computeTotalsWithRates(dto, rates, optional);
|
return this.computeTotalsWithRates(dto, rates, optional);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Totals with the platform-wide discount applied. This is the single choke
|
||||||
|
* point every real charge funnels through (calculateCost → invoices), so the
|
||||||
|
* discount automatically reaches previews, deploys, renewals and upgrades.
|
||||||
|
*/
|
||||||
|
async computeTotalsFromDb(dto: CalculateCostDto): Promise<CostTotals> {
|
||||||
|
const raw = await this.computeTotalsRawFromDb(dto);
|
||||||
|
const pct = await this.getGlobalDiscountPercent();
|
||||||
|
return this.applyGlobalDiscount(raw, pct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale totals (and each breakdown line) by the platform-wide discount. */
|
||||||
|
applyGlobalDiscount(totals: CostTotals, percentOff: number): CostTotals {
|
||||||
|
const pct = Math.min(100, Math.max(0, percentOff || 0));
|
||||||
|
if (pct <= 0) return totals;
|
||||||
|
const factor = 1 - pct / 100;
|
||||||
|
const scale = (n: number) => Math.round(n * factor);
|
||||||
|
return {
|
||||||
|
hourly: scale(totals.hourly),
|
||||||
|
monthly: scale(totals.monthly),
|
||||||
|
yearly: scale(totals.yearly),
|
||||||
|
breakdown: totals.breakdown.map((line) => ({
|
||||||
|
...line,
|
||||||
|
hourly: scale(line.hourly),
|
||||||
|
monthly: scale(line.monthly),
|
||||||
|
yearly: scale(line.yearly),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platform-wide discount percentage (0–100), cached with a short TTL. */
|
||||||
|
async getGlobalDiscountPercent(force = false): Promise<number> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
now - this.cachedGlobalDiscountAt < PricingCatalogService.GLOBAL_DISCOUNT_TTL_MS
|
||||||
|
) {
|
||||||
|
return this.cachedGlobalDiscountPct;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: GLOBAL_DISCOUNT_SETTING_KEY },
|
||||||
|
});
|
||||||
|
const parsed = setting ? parseInt(setting.value, 10) : 0;
|
||||||
|
this.cachedGlobalDiscountPct = Number.isFinite(parsed)
|
||||||
|
? Math.min(100, Math.max(0, parsed))
|
||||||
|
: 0;
|
||||||
|
this.cachedGlobalDiscountAt = now;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to read global discount setting: ${e?.message}`);
|
||||||
|
}
|
||||||
|
return this.cachedGlobalDiscountPct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the platform-wide discount percentage (Admin) and refresh the cache. */
|
||||||
|
async setGlobalDiscountPercent(percentOff: number): Promise<number> {
|
||||||
|
const clamped = Math.min(100, Math.max(0, Math.round(percentOff || 0)));
|
||||||
|
let setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: GLOBAL_DISCOUNT_SETTING_KEY },
|
||||||
|
});
|
||||||
|
if (!setting) {
|
||||||
|
setting = this.settingsRepo.create({
|
||||||
|
key: GLOBAL_DISCOUNT_SETTING_KEY,
|
||||||
|
value: String(clamped),
|
||||||
|
description: 'Platform-wide discount percentage applied to all pricing',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setting.value = String(clamped);
|
||||||
|
}
|
||||||
|
await this.settingsRepo.save(setting);
|
||||||
|
this.cachedGlobalDiscountPct = clamped;
|
||||||
|
this.cachedGlobalDiscountAt = Date.now();
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
computeTotalsWithRates(
|
computeTotalsWithRates(
|
||||||
dto: CalculateCostDto,
|
dto: CalculateCostDto,
|
||||||
rates: PricingRate[],
|
rates: PricingRate[],
|
||||||
@@ -739,13 +834,16 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
|
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
|
||||||
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
|
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
|
||||||
}
|
}
|
||||||
const storageQty =
|
// App resources (CPU/RAM/storage) bill per replica — each replica is a full
|
||||||
(dto.dbStorageSize
|
// copy of the user-selected footprint. The database is a single-replica
|
||||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
// workload, so its storage is billed once regardless of app replicas.
|
||||||
: 0) +
|
const dbStorage = dto.dbStorageSize
|
||||||
(dto.appStorageSize
|
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
: 0;
|
||||||
: 0);
|
const appStorage = dto.appStorageSize
|
||||||
|
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
|
: 0;
|
||||||
|
const storageQty = dbStorage + appStorage * replicas;
|
||||||
|
|
||||||
const map = new Map<PricingResourceType, number>();
|
const map = new Map<PricingResourceType, number>();
|
||||||
map.set(PricingResourceType.BASE_FEE, 1);
|
map.set(PricingResourceType.BASE_FEE, 1);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { PricingCatalogService } from './pricing-catalog.service';
|
||||||
|
import { CalculateCostDto } from './dto/billing.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticated pricing endpoints for the public landing page. Exposes the
|
||||||
|
* read-only pricing catalog and a cost estimator, both annotated with the
|
||||||
|
* platform-wide discount so the marketing site can show before/after prices.
|
||||||
|
*/
|
||||||
|
@ApiTags('Public Pricing')
|
||||||
|
@Controller('public/pricing')
|
||||||
|
export class PublicPricingController {
|
||||||
|
constructor(private readonly pricingCatalog: PricingCatalogService) {}
|
||||||
|
|
||||||
|
@Get('catalog')
|
||||||
|
@ApiOperation({ summary: 'Public pricing catalog + platform-wide discount' })
|
||||||
|
async getCatalog() {
|
||||||
|
const [catalog, globalDiscountPercent] = await Promise.all([
|
||||||
|
this.pricingCatalog.getCatalog(),
|
||||||
|
this.pricingCatalog.getGlobalDiscountPercent(),
|
||||||
|
]);
|
||||||
|
return { ...catalog, globalDiscountPercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({ summary: 'Estimate cost for a configuration (gross + discounted)' })
|
||||||
|
async calculate(@Body() dto: CalculateCostDto) {
|
||||||
|
const [gross, globalDiscountPercent] = await Promise.all([
|
||||||
|
this.pricingCatalog.computeTotalsRawFromDb(dto),
|
||||||
|
this.pricingCatalog.getGlobalDiscountPercent(),
|
||||||
|
]);
|
||||||
|
const net = this.pricingCatalog.applyGlobalDiscount(gross, globalDiscountPercent);
|
||||||
|
return {
|
||||||
|
gross: { hourly: gross.hourly, monthly: gross.monthly, yearly: gross.yearly },
|
||||||
|
net: { hourly: net.hourly, monthly: net.monthly, yearly: net.yearly },
|
||||||
|
breakdown: net.breakdown,
|
||||||
|
globalDiscountPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import type { BuildProgress } from './build.service';
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'build:progress:';
|
||||||
|
const TTL_SECONDS = 3600;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BuildProgressStore implements OnModuleDestroy {
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.redis = new Redis({
|
||||||
|
host: this.configService.get<string>('redis.host'),
|
||||||
|
port: this.configService.get<number>('redis.port'),
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
this.redis.connect().catch(() => {
|
||||||
|
// Redis may be unavailable in local unit tests — in-memory fallback remains in BuildService.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(deploymentId: string): Promise<BuildProgress | null> {
|
||||||
|
try {
|
||||||
|
const raw = await this.redis.get(`${KEY_PREFIX}${deploymentId}`);
|
||||||
|
return raw ? (JSON.parse(raw) as BuildProgress) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(deploymentId: string, progress: BuildProgress): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.set(
|
||||||
|
`${KEY_PREFIX}${deploymentId}`,
|
||||||
|
JSON.stringify(progress),
|
||||||
|
'EX',
|
||||||
|
TTL_SECONDS,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort — local map still holds progress for this replica.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(deploymentId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.del(`${KEY_PREFIX}${deploymentId}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
this.redis.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +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 { ScanService } from './scan.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';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module';
|
|||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
ClustersModule,
|
ClustersModule,
|
||||||
],
|
],
|
||||||
providers: [BuildService, ScanService],
|
providers: [BuildService, BuildProgressStore],
|
||||||
exports: [BuildService, ScanService],
|
exports: [BuildService],
|
||||||
})
|
})
|
||||||
export class BuildModule {}
|
export class BuildModule {}
|
||||||
|
|||||||
@@ -1,259 +1,140 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as path from 'path';
|
||||||
|
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';
|
||||||
|
import { SourceStorageService } from '../storage/source-storage.service';
|
||||||
|
|
||||||
/**
|
describe('BuildService', () => {
|
||||||
* Tests for build service:
|
let service: BuildService;
|
||||||
* • Nixpacks build preparation (BYO Dockerfile vs generated) for code runtimes
|
|
||||||
* • WordPress templated Dockerfile + helper-pod / entrypoint / zip-structure logic
|
|
||||||
*
|
|
||||||
* NOTE: like the rest of this file, the Nixpacks tests reproduce the pure logic
|
|
||||||
* locally instead of importing BuildService — the service pulls in the ESM
|
|
||||||
* `@kubernetes/client-node`, which this project's Jest config does not transform.
|
|
||||||
* Keep these copies in sync with nixpacksPrepareInitContainer in build.service.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
beforeEach(async () => {
|
||||||
* Tests for the WordPress build flow — specifically:
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
* 1. Helper pod PVC race condition (must wait for termination)
|
providers: [
|
||||||
* 2. WordPress Dockerfile generation correctness
|
BuildService,
|
||||||
* 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution
|
{
|
||||||
*/
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
describe('WordPress Dockerfile generation', () => {
|
get: jest.fn((key: string) => {
|
||||||
// Reproduce the wordpressDockerfile logic from build.service.ts
|
const map: Record<string, string> = {
|
||||||
function wordpressDockerfile(app: {
|
'build.namespace': 'cloudhost-builds',
|
||||||
runtimeVersion?: string;
|
'build.serviceAccount': 'kaniko-builder',
|
||||||
phpVersion?: string;
|
'registry.url': 'registry.local:5000',
|
||||||
codePath?: string;
|
};
|
||||||
port?: number;
|
return map[key];
|
||||||
}): string {
|
}),
|
||||||
const wpVersion = app.runtimeVersion || '6.7';
|
},
|
||||||
const phpVersion = app.phpVersion || '8.3';
|
},
|
||||||
const hasUploadedCode = !!app.codePath;
|
{ provide: ClustersService, useValue: {} },
|
||||||
|
{
|
||||||
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
|
provide: BuildProgressStore,
|
||||||
RUN docker-php-ext-install opcache
|
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
|
||||||
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
|
{ provide: RegistryService, useValue: {} },
|
||||||
${hasUploadedCode ? `COPY . /tmp/user-content
|
{
|
||||||
RUN mkdir -p /usr/src/wordpress-user
|
provide: SourceStorageService,
|
||||||
ENTRYPOINT ["cloudhost-entrypoint.sh"]
|
useValue: {
|
||||||
CMD []` : `CMD ["apache2-foreground"]`}
|
isObjectStorage: () => false,
|
||||||
EXPOSE 80
|
materializeToTempFile: jest.fn(),
|
||||||
`;
|
getSize: jest.fn(),
|
||||||
}
|
},
|
||||||
|
},
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Nixpacks build preparation', () => {
|
|
||||||
// Local copies of the pure logic in build.service.ts (see NOTE at top of file).
|
|
||||||
function shellQuote(value: string): string {
|
|
||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nixpacksPlanEnv(app: { runtime: AppRuntime; runtimeVersion?: string }): { name: string; value: string }[] {
|
|
||||||
const env: { name: string; value: string }[] = [];
|
|
||||||
if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) {
|
|
||||||
env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) });
|
|
||||||
}
|
|
||||||
if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) {
|
|
||||||
env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) });
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nixpacksPrepareInitContainer(
|
|
||||||
app: { runtime: AppRuntime; runtimeVersion?: string },
|
|
||||||
config: { nixpacksImage?: string; nixpacksBuildEnv?: string[] } = {},
|
|
||||||
): any {
|
|
||||||
const image = config.nixpacksImage || 'ghcr.io/railwayapp/nixpacks:latest';
|
|
||||||
const buildEnv = config.nixpacksBuildEnv || [];
|
|
||||||
const envFlags = buildEnv.map((kv) => `--env ${shellQuote(kv)}`).join(' ');
|
|
||||||
const planEnv = nixpacksPlanEnv(app);
|
|
||||||
return {
|
|
||||||
name: 'nixpacks-prepare',
|
|
||||||
image,
|
|
||||||
env: planEnv.length ? planEnv : undefined,
|
|
||||||
command: [
|
|
||||||
'sh',
|
|
||||||
'-c',
|
|
||||||
`if [ -f source/Dockerfile ]; then cp source/Dockerfile /workspace/Dockerfile; ` +
|
|
||||||
`else nixpacks build source --out source ${envFlags} && cp source/.nixpacks/Dockerfile /workspace/Dockerfile; fi`,
|
|
||||||
],
|
],
|
||||||
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
|
}).compile();
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => {
|
service = module.get(BuildService);
|
||||||
const script = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS }).command[2] as string;
|
|
||||||
expect(script).toContain('if [ -f source/Dockerfile ]');
|
|
||||||
expect(script).toContain('cp source/Dockerfile /workspace/Dockerfile');
|
|
||||||
expect(script).toContain('nixpacks build source --out source');
|
|
||||||
expect(script).toContain('cp source/.nixpacks/Dockerfile /workspace/Dockerfile');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the configured Nixpacks image (default when unset)', () => {
|
describe('generateDockerfile', () => {
|
||||||
expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest');
|
it('generates Go Dockerfile with requested runtime version', () => {
|
||||||
expect(
|
const app = {
|
||||||
nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image,
|
runtime: AppRuntime.GO,
|
||||||
).toBe('registry.local/nixpacks:1.2.3');
|
runtimeVersion: '1.22',
|
||||||
});
|
port: 8080,
|
||||||
|
} as Application;
|
||||||
|
|
||||||
it('bakes build-time mirror env into the build via --env flags', () => {
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
const script = nixpacksPrepareInitContainer(
|
|
||||||
{ runtime: AppRuntime.NODEJS },
|
|
||||||
{ nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] },
|
|
||||||
).command[2] as string;
|
|
||||||
expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => {
|
expect(dockerfile).toContain('FROM golang:1.22-alpine');
|
||||||
const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' });
|
expect(dockerfile).toContain('EXPOSE 8080');
|
||||||
expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' });
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('shellQuote escapes embedded single quotes safely', () => {
|
it('generates Go Dockerfile with cmd package when present in archive entries', () => {
|
||||||
expect(shellQuote("a'b")).toBe("'a'\\''b'");
|
const app = {
|
||||||
|
runtime: AppRuntime.GO,
|
||||||
|
runtimeVersion: '1.22',
|
||||||
|
port: 8080,
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app, [
|
||||||
|
'go.mod',
|
||||||
|
'cmd/server/main.go',
|
||||||
|
]) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('go build -a -installsuffix cgo -ldflags="-w -s" -o main ./cmd/server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates Node.js Dockerfile with default port', () => {
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.NODEJS,
|
||||||
|
runtimeVersion: '20',
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('FROM node:20');
|
||||||
|
expect(dockerfile).toContain('EXPOSE 3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates Laravel Dockerfile with artisan migrate', () => {
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.LARAVEL,
|
||||||
|
phpVersion: '8.3',
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('php:8.3');
|
||||||
|
expect(dockerfile).toContain('artisan migrate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates WordPress Dockerfile with official image', () => {
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.WORDPRESS,
|
||||||
|
runtimeVersion: '6.4',
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('wordpress:6.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates Django Dockerfile with detected settings module', () => {
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.DJANGO,
|
||||||
|
runtimeVersion: '3.12',
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app, ['myproject/settings.py']) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('DJANGO_SETTINGS_MODULE=myproject.settings');
|
||||||
|
expect(dockerfile).toContain('gunicorn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates .NET Dockerfile that restores nested csproj', () => {
|
||||||
|
const app = {
|
||||||
|
runtime: AppRuntime.DOTNET,
|
||||||
|
runtimeVersion: '8.0',
|
||||||
|
} as Application;
|
||||||
|
|
||||||
|
const dockerfile = (service as any).generateDockerfile(app, ['src/App/App.csproj']) as string;
|
||||||
|
|
||||||
|
expect(dockerfile).toContain('CSPROJ="src/App/App.csproj"');
|
||||||
|
expect(dockerfile).toContain('dotnet publish "$CSPROJ"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+937
-383
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,141 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AppRuntime } from '../common/enums';
|
||||||
|
import {
|
||||||
|
assertRuntimeMatch,
|
||||||
|
detectDjangoSettingsModule,
|
||||||
|
detectGoBuildTarget,
|
||||||
|
detectRuntimeFromArchive,
|
||||||
|
detectRuntimeFromEntries,
|
||||||
|
listArchiveEntries,
|
||||||
|
normalizeEntryPath,
|
||||||
|
stripCommonRootPrefix,
|
||||||
|
} from './runtime-detector';
|
||||||
|
|
||||||
|
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||||
|
|
||||||
|
describe('runtime-detector', () => {
|
||||||
|
describe('normalizeEntryPath / stripCommonRootPrefix', () => {
|
||||||
|
it('strips a single root folder prefix', () => {
|
||||||
|
const entries = ['myapp/package.json', 'myapp/src/index.js'];
|
||||||
|
expect(stripCommonRootPrefix(entries)).toEqual(['package.json', 'src/index.js']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes leading ./ segments', () => {
|
||||||
|
expect(normalizeEntryPath('./package.json')).toBe('package.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectRuntimeFromEntries', () => {
|
||||||
|
it('detects nodejs in nested layout', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['myapp/package.json']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.NODEJS, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects go from go.mod', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['go.mod', 'main.go']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.GO, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects laravel from artisan + composer.json', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['artisan', 'composer.json']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.LARAVEL, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects php from composer.json without artisan', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['composer.json', 'index.php']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.PHP, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects django from manage.py', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['manage.py', 'requirements.txt']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.DJANGO, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects python from requirements.txt', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['requirements.txt', 'app.py']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.PYTHON, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects dotnet from shallow csproj', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['src/App/App.csproj']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.DOTNET, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects wordpress wp-content migrate layout', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['themes/twenty/style.css', 'plugins/hello/hello.php']);
|
||||||
|
expect(result).toMatchObject({ runtime: AppRuntime.WORDPRESS, confidence: 'high' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns low confidence when package.json and composer.json coexist', () => {
|
||||||
|
const result = detectRuntimeFromEntries(['package.json', 'composer.json']);
|
||||||
|
expect(result).toMatchObject({ runtime: null, confidence: 'low' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertRuntimeMatch', () => {
|
||||||
|
it('passes when configured runtime matches detection', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertRuntimeMatch(AppRuntime.GO, {
|
||||||
|
runtime: AppRuntime.GO,
|
||||||
|
confidence: 'high',
|
||||||
|
signals: ['go.mod'],
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestException on high-confidence mismatch', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertRuntimeMatch(AppRuntime.NODEJS, {
|
||||||
|
runtime: AppRuntime.GO,
|
||||||
|
confidence: 'high',
|
||||||
|
signals: ['go.mod'],
|
||||||
|
}),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows upload when confidence is low', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertRuntimeMatch(AppRuntime.NODEJS, {
|
||||||
|
runtime: null,
|
||||||
|
confidence: 'low',
|
||||||
|
signals: [],
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listArchiveEntries + detectRuntimeFromArchive', () => {
|
||||||
|
it.each([
|
||||||
|
['nodejs-nested.zip', AppRuntime.NODEJS],
|
||||||
|
['go-mod.zip', AppRuntime.GO],
|
||||||
|
['laravel.zip', AppRuntime.LARAVEL],
|
||||||
|
['php-composer.zip', AppRuntime.PHP],
|
||||||
|
['django.zip', AppRuntime.DJANGO],
|
||||||
|
['wordpress-wp-content.zip', AppRuntime.WORDPRESS],
|
||||||
|
] as const)('reads %s as %s', async (fixture, runtime) => {
|
||||||
|
const zipPath = path.join(fixturesDir, fixture);
|
||||||
|
const entries = await listArchiveEntries(zipPath);
|
||||||
|
expect(entries.length).toBeGreaterThan(0);
|
||||||
|
const detected = await detectRuntimeFromArchive(zipPath);
|
||||||
|
expect(detected.runtime).toBe(runtime);
|
||||||
|
expect(detected.confidence).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects go inside a single nested root folder', async () => {
|
||||||
|
const zipPath = path.join(fixturesDir, 'go-nested-root.zip');
|
||||||
|
const detected = await detectRuntimeFromArchive(zipPath);
|
||||||
|
expect(detected.runtime).toBe(AppRuntime.GO);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dockerfile helpers', () => {
|
||||||
|
it('prefers cmd/*/main.go for go build target', () => {
|
||||||
|
expect(detectGoBuildTarget(['go.mod', 'cmd/server/main.go'])).toBe('./cmd/server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers django settings module from project layout', () => {
|
||||||
|
expect(detectDjangoSettingsModule(['myproject/settings.py'])).toBe('myproject.settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as yauzl from 'yauzl';
|
||||||
|
import { AppRuntime } from '../common/enums';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface RuntimeDetectionResult {
|
||||||
|
runtime: AppRuntime | null;
|
||||||
|
confidence: 'high' | 'low';
|
||||||
|
signals: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEntryPath(entry: string): string {
|
||||||
|
return entry.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip a single top-level folder when every entry lives under it. */
|
||||||
|
export function stripCommonRootPrefix(entries: string[]): string[] {
|
||||||
|
const normalized = entries.map(normalizeEntryPath).filter(Boolean);
|
||||||
|
if (normalized.length === 0) return [];
|
||||||
|
|
||||||
|
const firstSegments = new Set<string>();
|
||||||
|
for (const entry of normalized) {
|
||||||
|
const seg = entry.split('/')[0];
|
||||||
|
if (seg) firstSegments.add(seg);
|
||||||
|
}
|
||||||
|
if (firstSegments.size !== 1) return normalized;
|
||||||
|
|
||||||
|
const root = [...firstSegments][0];
|
||||||
|
const allUnderRoot = normalized.every((entry) => entry === root || entry.startsWith(`${root}/`));
|
||||||
|
if (!allUnderRoot) return normalized;
|
||||||
|
|
||||||
|
const nestedUnderRoot = normalized.filter((entry) => entry.startsWith(`${root}/`));
|
||||||
|
if (nestedUnderRoot.length === 0) return normalized;
|
||||||
|
|
||||||
|
if (normalized.length === 1) {
|
||||||
|
const parts = normalized[0].split('/').filter(Boolean);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return [parts[1]];
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.map((entry) => {
|
||||||
|
if (entry === root) return entry;
|
||||||
|
return entry.slice(root.length + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBasename(entries: string[], name: string): boolean {
|
||||||
|
return entries.some((entry) => path.posix.basename(entry) === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDirectory(entries: string[], dirName: string): boolean {
|
||||||
|
return entries.some((entry) => {
|
||||||
|
const parts = entry.split('/').filter(Boolean);
|
||||||
|
return parts.includes(dirName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasShallowCsproj(entries: string[]): string | null {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.endsWith('.csproj')) continue;
|
||||||
|
const depth = entry.split('/').filter(Boolean).length;
|
||||||
|
if (depth <= 3) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectRuntimeFromEntries(rawEntries: string[]): RuntimeDetectionResult {
|
||||||
|
const entries = stripCommonRootPrefix(rawEntries);
|
||||||
|
const signals: string[] = [];
|
||||||
|
|
||||||
|
const note = (signal: string) => {
|
||||||
|
if (!signals.includes(signal)) signals.push(signal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasWpAdmin = hasDirectory(entries, 'wp-admin');
|
||||||
|
const hasWpContent = hasDirectory(entries, 'wp-content');
|
||||||
|
const hasWpConfig =
|
||||||
|
hasBasename(entries, 'wp-config.php') ||
|
||||||
|
hasBasename(entries, 'wp-config-sample.php') ||
|
||||||
|
entries.some((e) => /wp-config[^/]*\.php$/i.test(e));
|
||||||
|
const hasThemes = hasDirectory(entries, 'themes');
|
||||||
|
const hasPlugins = hasDirectory(entries, 'plugins');
|
||||||
|
|
||||||
|
if (hasWpAdmin || (hasWpContent && hasWpConfig) || (hasThemes && hasPlugins && !hasWpAdmin)) {
|
||||||
|
if (hasWpAdmin) note('wp-admin');
|
||||||
|
if (hasWpContent) note('wp-content');
|
||||||
|
if (hasWpConfig) note('wp-config.php');
|
||||||
|
if (hasThemes) note('themes');
|
||||||
|
if (hasPlugins) note('plugins');
|
||||||
|
return { runtime: AppRuntime.WORDPRESS, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasArtisan = hasBasename(entries, 'artisan');
|
||||||
|
const hasComposer = hasBasename(entries, 'composer.json');
|
||||||
|
if (hasArtisan && hasComposer) {
|
||||||
|
note('artisan');
|
||||||
|
note('composer.json');
|
||||||
|
return { runtime: AppRuntime.LARAVEL, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasManagePy = hasBasename(entries, 'manage.py');
|
||||||
|
if (hasManagePy) {
|
||||||
|
note('manage.py');
|
||||||
|
if (hasBasename(entries, 'requirements.txt')) note('requirements.txt');
|
||||||
|
return { runtime: AppRuntime.DJANGO, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBasename(entries, 'go.mod')) {
|
||||||
|
note('go.mod');
|
||||||
|
return { runtime: AppRuntime.GO, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
const csproj = hasShallowCsproj(entries);
|
||||||
|
if (csproj) {
|
||||||
|
note(csproj);
|
||||||
|
return { runtime: AppRuntime.DOTNET, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPackageJson = hasBasename(entries, 'package.json');
|
||||||
|
if (hasPackageJson && hasComposer) {
|
||||||
|
note('package.json');
|
||||||
|
note('composer.json');
|
||||||
|
return { runtime: null, confidence: 'low', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasComposer) {
|
||||||
|
note('composer.json');
|
||||||
|
return { runtime: AppRuntime.PHP, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequirements = hasBasename(entries, 'requirements.txt');
|
||||||
|
const hasPyproject = hasBasename(entries, 'pyproject.toml');
|
||||||
|
if (hasRequirements || hasPyproject) {
|
||||||
|
if (hasRequirements) note('requirements.txt');
|
||||||
|
if (hasPyproject) note('pyproject.toml');
|
||||||
|
return { runtime: AppRuntime.PYTHON, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPackageJson) {
|
||||||
|
note('package.json');
|
||||||
|
return { runtime: AppRuntime.NODEJS, confidence: 'high', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runtime: null, confidence: 'low', signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertRuntimeMatch(configured: AppRuntime, detected: RuntimeDetectionResult): void {
|
||||||
|
if (detected.confidence !== 'high' || detected.runtime === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (detected.runtime === configured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: `Selected runtime "${configured}" does not match the uploaded source (detected "${detected.runtime}").`,
|
||||||
|
configured,
|
||||||
|
detected: detected.runtime,
|
||||||
|
signals: detected.signals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listZipEntries(archivePath: string): Promise<string[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
yauzl.open(archivePath, { lazyEntries: true }, (err, zipfile) => {
|
||||||
|
if (err || !zipfile) {
|
||||||
|
reject(err ?? new Error(`Failed to open zip: ${archivePath}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: string[] = [];
|
||||||
|
zipfile.readEntry();
|
||||||
|
zipfile.on('entry', (entry) => {
|
||||||
|
entries.push(entry.fileName);
|
||||||
|
zipfile.readEntry();
|
||||||
|
});
|
||||||
|
zipfile.on('end', () => resolve(entries));
|
||||||
|
zipfile.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTarEntries(archivePath: string): Promise<string[]> {
|
||||||
|
const { stdout } = await execFileAsync('tar', ['-tf', archivePath]);
|
||||||
|
return stdout.split('\n').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List relative paths up to depth 2 inside a directory (for extracted sources). */
|
||||||
|
export function listDirectoryEntriesShallow(dirPath: string, maxDepth = 2): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
const walk = (current: string, depth: number) => {
|
||||||
|
if (depth > maxDepth) return;
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(current);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
const full = path.join(current, name);
|
||||||
|
const rel = path.relative(dirPath, full).replace(/\\/g, '/');
|
||||||
|
results.push(rel);
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
walk(full, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(dirPath, 0);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listArchiveEntries(archivePath: string): Promise<string[]> {
|
||||||
|
const resolved = path.resolve(archivePath);
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return listDirectoryEntriesShallow(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = resolved.toLowerCase();
|
||||||
|
if (lower.endsWith('.zip')) {
|
||||||
|
return listZipEntries(resolved);
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
|
||||||
|
return listTarEntries(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectRuntimeFromArchive(archivePath: string): Promise<RuntimeDetectionResult> {
|
||||||
|
const entries = await listArchiveEntries(archivePath);
|
||||||
|
return detectRuntimeFromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateRuntimeFromArchive(
|
||||||
|
configured: AppRuntime,
|
||||||
|
archivePath: string | null | undefined,
|
||||||
|
): Promise<RuntimeDetectionResult> {
|
||||||
|
if (!archivePath) {
|
||||||
|
return { runtime: null, confidence: 'low', signals: [] };
|
||||||
|
}
|
||||||
|
const detected = await detectRuntimeFromArchive(archivePath);
|
||||||
|
assertRuntimeMatch(configured, detected);
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer `cmd/<name>/main.go` when present. */
|
||||||
|
export function detectGoBuildTarget(entries: string[]): string {
|
||||||
|
const normalized = stripCommonRootPrefix(entries);
|
||||||
|
const cmdMain = normalized.find((entry) => /^cmd\/[^/]+\/main\.go$/.test(entry));
|
||||||
|
if (cmdMain) {
|
||||||
|
return `./${cmdMain.replace(/\/main\.go$/, '')}`;
|
||||||
|
}
|
||||||
|
return '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectShallowCsproj(entries: string[]): string | null {
|
||||||
|
return hasShallowCsproj(stripCommonRootPrefix(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Infer DJANGO_SETTINGS_MODULE from settings.py layout. */
|
||||||
|
export function detectDjangoSettingsModule(entries: string[]): string {
|
||||||
|
for (const entry of entries.map(normalizeEntryPath).filter(Boolean)) {
|
||||||
|
const parts = entry.split('/').filter(Boolean);
|
||||||
|
const settingsIdx = parts.findIndex((part) => part === 'settings.py');
|
||||||
|
if (settingsIdx === 1) return `${parts[0]}.settings`;
|
||||||
|
if (settingsIdx === 2) return `${parts[0]}.${parts[1]}.settings`;
|
||||||
|
}
|
||||||
|
return 'config.settings';
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for the pure Trivy-report aggregation logic in ScanService.summarize.
|
|
||||||
*
|
|
||||||
* NOTE: like the other build specs, this reproduces the pure logic locally rather
|
|
||||||
* than importing ScanService — the service pulls in the ESM `@kubernetes/client-node`,
|
|
||||||
* which this project's Jest config does not transform. Keep in sync with scan.service.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface VulnerabilitySummary {
|
|
||||||
critical: number;
|
|
||||||
high: number;
|
|
||||||
medium: number;
|
|
||||||
low: number;
|
|
||||||
unknown: number;
|
|
||||||
total: number;
|
|
||||||
scannedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTrivyJson(output: string): any | null {
|
|
||||||
if (!output) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(output);
|
|
||||||
} catch {
|
|
||||||
const start = output.indexOf('{');
|
|
||||||
const end = output.lastIndexOf('}');
|
|
||||||
if (start >= 0 && end > start) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(output.slice(start, end + 1));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(trivyOutput: string): VulnerabilitySummary {
|
|
||||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
||||||
const parsed = parseTrivyJson(trivyOutput);
|
|
||||||
const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : [];
|
|
||||||
for (const result of results) {
|
|
||||||
const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : [];
|
|
||||||
for (const v of vulns) {
|
|
||||||
const sev = String(v?.Severity || 'UNKNOWN').toUpperCase();
|
|
||||||
if (sev === 'CRITICAL') counts.critical++;
|
|
||||||
else if (sev === 'HIGH') counts.high++;
|
|
||||||
else if (sev === 'MEDIUM') counts.medium++;
|
|
||||||
else if (sev === 'LOW') counts.low++;
|
|
||||||
else counts.unknown++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...counts,
|
|
||||||
total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown,
|
|
||||||
scannedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ScanService.summarize', () => {
|
|
||||||
it('counts vulnerabilities per severity across results', () => {
|
|
||||||
const report = JSON.stringify({
|
|
||||||
Results: [
|
|
||||||
{ Vulnerabilities: [{ Severity: 'CRITICAL' }, { Severity: 'HIGH' }, { Severity: 'high' }] },
|
|
||||||
{ Vulnerabilities: [{ Severity: 'MEDIUM' }, { Severity: 'LOW' }, { Severity: 'WeIrD' }] },
|
|
||||||
{ Vulnerabilities: null },
|
|
||||||
{},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const s = summarize(report);
|
|
||||||
expect(s).toMatchObject({ critical: 1, high: 2, medium: 1, low: 1, unknown: 1, total: 6 });
|
|
||||||
expect(typeof s.scannedAt).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns all-zero summary for a clean image', () => {
|
|
||||||
expect(summarize(JSON.stringify({ Results: [{ Target: 'x' }] }))).toMatchObject({
|
|
||||||
critical: 0,
|
|
||||||
high: 0,
|
|
||||||
medium: 0,
|
|
||||||
low: 0,
|
|
||||||
unknown: 0,
|
|
||||||
total: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates leading log noise before the JSON', () => {
|
|
||||||
const noisy = `2026-06-20 INFO Need to update DB\n{"Results":[{"Vulnerabilities":[{"Severity":"CRITICAL"}]}]}`;
|
|
||||||
expect(summarize(noisy).critical).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a zero summary on unparseable output', () => {
|
|
||||||
expect(summarize('not json at all').total).toBe(0);
|
|
||||||
expect(summarize('').total).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import * as k8s from '@kubernetes/client-node';
|
|
||||||
import { Application } from '../applications/entities/application.entity';
|
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
|
||||||
import { RegistryService } from '../kubernetes/registry.service';
|
|
||||||
|
|
||||||
export interface VulnerabilitySummary {
|
|
||||||
critical: number;
|
|
||||||
high: number;
|
|
||||||
medium: number;
|
|
||||||
low: number;
|
|
||||||
unknown: number;
|
|
||||||
total: number;
|
|
||||||
scannedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report-only image vulnerability scanning with Trivy. Runs a one-shot K8s Job
|
|
||||||
* that scans the freshly-pushed image in the in-cluster registry and stores a
|
|
||||||
* severity summary on the deployment. Never blocks a deployment — any failure is
|
|
||||||
* logged and ignored.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ScanService {
|
|
||||||
private readonly logger = new Logger(ScanService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly clustersService: ClustersService,
|
|
||||||
private readonly registryService: RegistryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Aggregate a Trivy JSON report (raw stdout) into per-severity counts. Pure & testable. */
|
|
||||||
summarize(trivyOutput: string): VulnerabilitySummary {
|
|
||||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
||||||
const parsed = this.parseTrivyJson(trivyOutput);
|
|
||||||
const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : [];
|
|
||||||
for (const result of results) {
|
|
||||||
const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : [];
|
|
||||||
for (const v of vulns) {
|
|
||||||
const sev = String(v?.Severity || 'UNKNOWN').toUpperCase();
|
|
||||||
if (sev === 'CRITICAL') counts.critical++;
|
|
||||||
else if (sev === 'HIGH') counts.high++;
|
|
||||||
else if (sev === 'MEDIUM') counts.medium++;
|
|
||||||
else if (sev === 'LOW') counts.low++;
|
|
||||||
else counts.unknown++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...counts,
|
|
||||||
total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown,
|
|
||||||
scannedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trivy prints clean JSON to stdout, but tolerate any leading noise from the log stream. */
|
|
||||||
private parseTrivyJson(output: string): any | null {
|
|
||||||
if (!output) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(output);
|
|
||||||
} catch {
|
|
||||||
const start = output.indexOf('{');
|
|
||||||
const end = output.lastIndexOf('}');
|
|
||||||
if (start >= 0 && end > start) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(output.slice(start, end + 1));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a pushed image and return a severity summary, or null on any failure.
|
|
||||||
* Report-only: callers must treat null as "no data", never as a deploy gate.
|
|
||||||
*/
|
|
||||||
async scanImage(app: Application, imageUri: string): Promise<VulnerabilitySummary | null> {
|
|
||||||
if (this.configService.get<boolean>('build.scanEnabled') === false) return null;
|
|
||||||
|
|
||||||
const buildNs = this.registryService.getBuildNamespace();
|
|
||||||
const image = this.configService.get<string>('build.trivyImage') || 'aquasec/trivy:latest';
|
|
||||||
const dbRepo = this.configService.get<string>('build.trivyDbRepository') || '';
|
|
||||||
const timeoutSeconds = this.configService.get<number>('build.scanTimeoutSeconds') || 300;
|
|
||||||
const { username, password } = this.registryService.getRegistryCredentials();
|
|
||||||
|
|
||||||
const jobName = `scan-${app.name}-${Date.now()}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
|
|
||||||
const kc = new k8s.KubeConfig();
|
|
||||||
kc.loadFromString(cluster.kubeconfig);
|
|
||||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
|
||||||
|
|
||||||
const env: { name: string; value: string }[] = [
|
|
||||||
{ name: 'TRIVY_INSECURE', value: 'true' }, // in-cluster registry is plain HTTP
|
|
||||||
{ name: 'TRIVY_NON_SSL', value: 'true' },
|
|
||||||
];
|
|
||||||
if (username) env.push({ name: 'TRIVY_USERNAME', value: username });
|
|
||||||
if (password) env.push({ name: 'TRIVY_PASSWORD', value: password });
|
|
||||||
if (dbRepo) env.push({ name: 'TRIVY_DB_REPOSITORY', value: dbRepo });
|
|
||||||
|
|
||||||
const job: k8s.V1Job = {
|
|
||||||
apiVersion: 'batch/v1',
|
|
||||||
kind: 'Job',
|
|
||||||
metadata: { name: jobName, namespace: buildNs },
|
|
||||||
spec: {
|
|
||||||
backoffLimit: 0,
|
|
||||||
ttlSecondsAfterFinished: 120,
|
|
||||||
template: {
|
|
||||||
spec: {
|
|
||||||
restartPolicy: 'Never',
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
name: 'trivy',
|
|
||||||
image,
|
|
||||||
imagePullPolicy: 'IfNotPresent',
|
|
||||||
env,
|
|
||||||
args: ['image', '--quiet', '--no-progress', '--format', 'json', '--severity', 'CRITICAL,HIGH,MEDIUM,LOW', imageUri],
|
|
||||||
resources: {
|
|
||||||
requests: { cpu: '250m', memory: '512Mi' },
|
|
||||||
limits: { cpu: '1', memory: '1Gi' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await batchApi.createNamespacedJob({ namespace: buildNs, body: job });
|
|
||||||
await this.waitForJob(batchApi, jobName, buildNs, timeoutSeconds);
|
|
||||||
|
|
||||||
const output = await this.getJobPodLogs(coreApi, jobName, buildNs);
|
|
||||||
const summary = this.summarize(output);
|
|
||||||
|
|
||||||
await batchApi
|
|
||||||
.deleteNamespacedJob({ name: jobName, namespace: buildNs, gracePeriodSeconds: 0, propagationPolicy: 'Foreground' })
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
this.logger.log(`Scan complete for ${imageUri}: ${summary.critical}C/${summary.high}H/${summary.medium}M/${summary.low}L`);
|
|
||||||
return summary;
|
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.warn(`Image scan failed for ${imageUri} (report-only, ignored): ${e.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForJob(batchApi: k8s.BatchV1Api, jobName: string, namespace: string, timeoutSeconds: number): Promise<void> {
|
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
|
||||||
if (job.status?.succeeded) return;
|
|
||||||
if ((job.status?.failed ?? 0) > 0) return; // Trivy exits non-zero on findings with some flags; read logs anyway
|
|
||||||
await new Promise((r) => setTimeout(r, 4000));
|
|
||||||
}
|
|
||||||
throw new Error(`Scan job ${jobName} timed out after ${timeoutSeconds}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getJobPodLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
|
|
||||||
const pods = await coreApi.listNamespacedPod({ namespace, labelSelector: `job-name=${jobName}` });
|
|
||||||
const podName = pods.items[0]?.metadata?.name;
|
|
||||||
if (!podName) throw new Error(`No pod found for scan job ${jobName}`);
|
|
||||||
return coreApi.readNamespacedPodLog({ name: podName, namespace, container: 'trivy' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1102,133 +1102,9 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
await this.ensureK3sRegistryMirrors(appsApi, registryUrl);
|
await this.ensureK3sRegistryMirrors(appsApi, registryUrl);
|
||||||
|
|
||||||
// ── 8. MinIO (S3-compatible) for application source archives ──
|
|
||||||
await this.ensureMinioInfrastructure(coreApi, appsApi, buildNs);
|
|
||||||
|
|
||||||
this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`);
|
this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Provision in-cluster MinIO (Deployment + PVC + Service + credentials Secret)
|
|
||||||
* in the build namespace. Application source archives are uploaded here by the
|
|
||||||
* API and pulled by build pods via presigned URLs.
|
|
||||||
*/
|
|
||||||
private async ensureMinioInfrastructure(coreApi: k8s.CoreV1Api, appsApi: k8s.AppsV1Api, buildNs: string): Promise<void> {
|
|
||||||
const accessKey = this.configService.get<string>('minio.accessKey') || 'cloudhost';
|
|
||||||
const secretKey = this.configService.get<string>('minio.secretKey') || '';
|
|
||||||
const pvcName = 'minio-data';
|
|
||||||
const deployName = 'minio';
|
|
||||||
const svcName = 'minio';
|
|
||||||
const secretName = 'minio-credentials';
|
|
||||||
|
|
||||||
// 1. Credentials Secret (shared by the MinIO server env and the API client)
|
|
||||||
const minioSecret: k8s.V1Secret = {
|
|
||||||
metadata: { name: secretName, namespace: buildNs },
|
|
||||||
type: 'Opaque',
|
|
||||||
data: {
|
|
||||||
accesskey: Buffer.from(accessKey).toString('base64'),
|
|
||||||
secretkey: Buffer.from(secretKey).toString('base64'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await coreApi.readNamespacedSecret({ name: secretName, namespace: buildNs });
|
|
||||||
await coreApi.replaceNamespacedSecret({ name: secretName, namespace: buildNs, body: minioSecret });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 404 || err.body?.code === 404) {
|
|
||||||
await coreApi.createNamespacedSecret({ namespace: buildNs, body: minioSecret });
|
|
||||||
this.logger.log(`Created ${secretName} Secret`);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Data PVC
|
|
||||||
try {
|
|
||||||
await coreApi.readNamespacedPersistentVolumeClaim({ name: pvcName, namespace: buildNs });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 404 || err.body?.code === 404) {
|
|
||||||
await coreApi.createNamespacedPersistentVolumeClaim({
|
|
||||||
namespace: buildNs,
|
|
||||||
body: {
|
|
||||||
metadata: { name: pvcName, namespace: buildNs },
|
|
||||||
spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: '20Gi' } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(`Created PVC "${pvcName}" (20Gi)`);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Deployment
|
|
||||||
try {
|
|
||||||
await appsApi.readNamespacedDeployment({ name: deployName, namespace: buildNs });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 404 || err.body?.code === 404) {
|
|
||||||
await appsApi.createNamespacedDeployment({
|
|
||||||
namespace: buildNs,
|
|
||||||
body: {
|
|
||||||
metadata: { name: deployName, namespace: buildNs, labels: { app: 'minio' } },
|
|
||||||
spec: {
|
|
||||||
replicas: 1,
|
|
||||||
selector: { matchLabels: { app: 'minio' } },
|
|
||||||
template: {
|
|
||||||
metadata: { labels: { app: 'minio' } },
|
|
||||||
spec: {
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
name: 'minio',
|
|
||||||
image: process.env.MINIO_IMAGE || 'minio/minio:latest',
|
|
||||||
args: ['server', '/data', '--console-address', ':9001'],
|
|
||||||
env: [
|
|
||||||
{ name: 'MINIO_ROOT_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'accesskey' } } },
|
|
||||||
{ name: 'MINIO_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'secretkey' } } },
|
|
||||||
],
|
|
||||||
ports: [{ containerPort: 9000 }, { containerPort: 9001 }],
|
|
||||||
volumeMounts: [{ name: 'data', mountPath: '/data' }],
|
|
||||||
resources: {
|
|
||||||
requests: { cpu: '100m', memory: '256Mi' },
|
|
||||||
limits: { cpu: '1', memory: '1Gi' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
volumes: [{ name: 'data', persistentVolumeClaim: { claimName: pvcName } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(`Created MinIO Deployment`);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. ClusterIP Service (API :9000, console :9001)
|
|
||||||
try {
|
|
||||||
await coreApi.readNamespacedService({ name: svcName, namespace: buildNs });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 404 || err.body?.code === 404) {
|
|
||||||
await coreApi.createNamespacedService({
|
|
||||||
namespace: buildNs,
|
|
||||||
body: {
|
|
||||||
metadata: { name: svcName, namespace: buildNs, labels: { app: 'minio' } },
|
|
||||||
spec: {
|
|
||||||
selector: { app: 'minio' },
|
|
||||||
ports: [
|
|
||||||
{ name: 'api', port: 9000, targetPort: 9000 as any, protocol: 'TCP' },
|
|
||||||
{ name: 'console', port: 9001, targetPort: 9001 as any, protocol: 'TCP' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(`Created MinIO Service`);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */
|
/** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */
|
||||||
private async ensureK3sRegistryMirrors(appsApi: k8s.AppsV1Api, registryUrl: string): Promise<void> {
|
private async ensureK3sRegistryMirrors(appsApi: k8s.AppsV1Api, registryUrl: string): Promise<void> {
|
||||||
const namespace = 'kube-system';
|
const namespace = 'kube-system';
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Global, Module, OnApplicationShutdown, Logger } from '@nestjs/common';
|
|
||||||
import { ModuleRef } from '@nestjs/core';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import IORedis, { Redis } from 'ioredis';
|
|
||||||
|
|
||||||
/** Injection token for the shared ioredis client. */
|
|
||||||
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared Redis client used for build state (progress / cancel flags / build
|
|
||||||
* sessions) so the data survives across backend replicas — unlike the previous
|
|
||||||
* in-memory Maps that only worked with a single instance.
|
|
||||||
*
|
|
||||||
* Reuses the same connection details as the Bull queue (`redis.host/port`).
|
|
||||||
*/
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: REDIS_CLIENT,
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (configService: ConfigService): Redis => {
|
|
||||||
const client = new IORedis({
|
|
||||||
host: configService.get<string>('redis.host'),
|
|
||||||
port: configService.get<number>('redis.port'),
|
|
||||||
// Build state writes are best-effort telemetry — never let a Redis
|
|
||||||
// hiccup take down the request that triggered them.
|
|
||||||
maxRetriesPerRequest: 2,
|
|
||||||
enableOfflineQueue: true,
|
|
||||||
});
|
|
||||||
client.on('error', (err) => {
|
|
||||||
new Logger('RedisClient').warn(`Redis connection error: ${err.message}`);
|
|
||||||
});
|
|
||||||
return client;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [REDIS_CLIENT],
|
|
||||||
})
|
|
||||||
export class RedisModule implements OnApplicationShutdown {
|
|
||||||
constructor(private readonly moduleRef: ModuleRef) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const client = this.moduleRef.get<Redis>(REDIS_CLIENT, { strict: false });
|
|
||||||
await client?.quit();
|
|
||||||
} catch {
|
|
||||||
/* ignore shutdown errors */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { StorageService } from './storage.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global module exposing the MinIO-backed {@link StorageService} so both the API
|
|
||||||
* (source upload) and the build pipeline (presigned download) can inject it.
|
|
||||||
*/
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [StorageService],
|
|
||||||
exports: [StorageService],
|
|
||||||
})
|
|
||||||
export class StorageModule {}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import * as Minio from 'minio';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object storage for application source archives, backed by the in-cluster MinIO
|
|
||||||
* (S3-compatible). Replaces the previous local-disk + PVC + kubectl-cp upload path:
|
|
||||||
* the API streams the uploaded zip straight to MinIO, and build pods pull it via a
|
|
||||||
* short-lived presigned URL (no credentials, no kubectl, no helper pod).
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class StorageService {
|
|
||||||
private readonly logger = new Logger(StorageService.name);
|
|
||||||
private readonly client: Minio.Client;
|
|
||||||
private readonly bucket: string;
|
|
||||||
private bucketReady = false;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.bucket = this.configService.get<string>('minio.bucket') || 'app-sources';
|
|
||||||
this.client = new Minio.Client({
|
|
||||||
endPoint: this.configService.get<string>('minio.endpoint') || 'minio.cloudhost-builds.svc.cluster.local',
|
|
||||||
port: this.configService.get<number>('minio.port') || 9000,
|
|
||||||
useSSL: this.configService.get<boolean>('minio.useSSL') || false,
|
|
||||||
accessKey: this.configService.get<string>('minio.accessKey') || 'cloudhost',
|
|
||||||
secretKey: this.configService.get<string>('minio.secretKey') || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Object key for an app's source archive. */
|
|
||||||
sourceKey(userId: string, appId: string): string {
|
|
||||||
return `${userId}/${appId}/source.zip`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureBucket(): Promise<void> {
|
|
||||||
if (this.bucketReady) return;
|
|
||||||
const exists = await this.client.bucketExists(this.bucket).catch(() => false);
|
|
||||||
if (!exists) {
|
|
||||||
await this.client.makeBucket(this.bucket);
|
|
||||||
this.logger.log(`Created MinIO bucket "${this.bucket}"`);
|
|
||||||
}
|
|
||||||
this.bucketReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upload an app's source archive; returns the stored object key (saved as app.codePath). */
|
|
||||||
async putSource(userId: string, appId: string, data: Buffer): Promise<string> {
|
|
||||||
await this.ensureBucket();
|
|
||||||
const key = this.sourceKey(userId, appId);
|
|
||||||
await this.client.putObject(this.bucket, key, data, data.length, { 'Content-Type': 'application/zip' });
|
|
||||||
this.logger.log(`Uploaded source ${(data.length / 1024 / 1024).toFixed(1)}MB → ${this.bucket}/${key}`);
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Short-lived presigned GET URL the build pod uses to download the source. */
|
|
||||||
async presignSourceGet(key: string, expirySeconds = 3600): Promise<string> {
|
|
||||||
return this.client.presignedGetObject(this.bucket, key, expirySeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort delete of an app's source object (e.g. on app deletion). */
|
|
||||||
async removeSource(key: string): Promise<void> {
|
|
||||||
await this.client.removeObject(this.bucket, key).catch((e) => {
|
|
||||||
this.logger.warn(`Failed to remove source ${key}: ${e.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
@@ -125,45 +130,9 @@ export default () => ({
|
|||||||
password: process.env.REGISTRY_PASSWORD || '',
|
password: process.env.REGISTRY_PASSWORD || '',
|
||||||
},
|
},
|
||||||
|
|
||||||
// In-cluster MinIO (S3-compatible) for application source archives.
|
|
||||||
minio: {
|
|
||||||
endpoint: process.env.MINIO_ENDPOINT || 'minio.cloudhost-builds.svc.cluster.local',
|
|
||||||
port: parseInt(process.env.MINIO_PORT || '9000', 10),
|
|
||||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY || 'cloudhost',
|
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || 'CloudHost2024!Minio',
|
|
||||||
bucket: process.env.MINIO_BUCKET || 'app-sources',
|
|
||||||
},
|
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
|
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
|
||||||
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
|
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
|
||||||
/** Max number of build+deploy pipelines processed concurrently across the queue. */
|
|
||||||
concurrency: parseInt(process.env.BUILD_CONCURRENCY || '3', 10),
|
|
||||||
/** Per-image-build timeout (Kaniko job) in seconds. */
|
|
||||||
timeoutSeconds: parseInt(process.env.BUILD_TIMEOUT_SECONDS || '600', 10),
|
|
||||||
/** Nixpacks builder image used to generate a Dockerfile for code runtimes. */
|
|
||||||
nixpacksImage: process.env.NIXPACKS_IMAGE || 'ghcr.io/railwayapp/nixpacks:latest',
|
|
||||||
/**
|
|
||||||
* Build-time env baked into Nixpacks-generated images (mirrors/proxies for the
|
|
||||||
* Iran network, e.g. "NPM_CONFIG_REGISTRY=https://registry.npmmirror.com").
|
|
||||||
* Comma-separated KEY=VALUE pairs — set per the Phase 0 spike findings.
|
|
||||||
*/
|
|
||||||
nixpacksBuildEnv: (process.env.NIXPACKS_BUILD_ENV || '')
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
/** Report-only Trivy image scan after a successful build. */
|
|
||||||
scanEnabled: process.env.BUILD_SCAN_ENABLED !== 'false',
|
|
||||||
trivyImage: process.env.TRIVY_IMAGE || 'aquasec/trivy:latest',
|
|
||||||
/** Optional mirror for Trivy's vulnerability DB (Iran network); empty = default ghcr.io. */
|
|
||||||
trivyDbRepository: process.env.TRIVY_DB_REPOSITORY || '',
|
|
||||||
/** Max seconds to wait for the Trivy scan job. */
|
|
||||||
scanTimeoutSeconds: parseInt(process.env.BUILD_SCAN_TIMEOUT_SECONDS || '300', 10),
|
|
||||||
/** Registry garbage collection: keep the N most recent image tags per app repo. */
|
|
||||||
registryGcEnabled: process.env.REGISTRY_GC_ENABLED !== 'false',
|
|
||||||
registryKeepVersions: parseInt(process.env.REGISTRY_KEEP_VERSIONS || '3', 10),
|
|
||||||
registryGcIntervalMs: parseInt(process.env.REGISTRY_GC_INTERVAL_MS || '86400000', 10), // daily
|
|
||||||
},
|
},
|
||||||
|
|
||||||
elasticsearch: {
|
elasticsearch: {
|
||||||
@@ -190,6 +159,14 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sourceStorage: {
|
||||||
|
endpoint: process.env.SOURCE_STORAGE_ENDPOINT,
|
||||||
|
region: process.env.SOURCE_STORAGE_REGION || 'us-east-1',
|
||||||
|
bucket: process.env.SOURCE_STORAGE_BUCKET,
|
||||||
|
accessKey: process.env.SOURCE_STORAGE_ACCESS_KEY,
|
||||||
|
secretKey: process.env.SOURCE_STORAGE_SECRET_KEY,
|
||||||
|
},
|
||||||
|
|
||||||
platform: {
|
platform: {
|
||||||
domain: resolvePlatformDomainFromEnv(),
|
domain: resolvePlatformDomainFromEnv(),
|
||||||
previewRootDomain: resolvePreviewRootDomainFromEnv(),
|
previewRootDomain: resolvePreviewRootDomainFromEnv(),
|
||||||
|
|||||||
@@ -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')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/** Bull queue name for build+deploy pipelines. */
|
|
||||||
export const DEPLOY_QUEUE = 'app-deploy';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Process, Processor } from '@nestjs/bull';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bull';
|
|
||||||
import { DeploymentsService, DeploymentJobData } from './deployments.service';
|
|
||||||
import { DEPLOY_QUEUE } from './deployment.constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes build+deploy pipelines off the `app-deploy` queue with a bounded
|
|
||||||
* concurrency (BUILD_CONCURRENCY, default 3) so simultaneous user deploys can't
|
|
||||||
* flood the cluster with Kaniko build jobs (2 CPU / 4Gi each).
|
|
||||||
*
|
|
||||||
* Concurrency is read at module-load time from env because Bull's `@Process`
|
|
||||||
* decorator option must be a constant.
|
|
||||||
*/
|
|
||||||
@Processor(DEPLOY_QUEUE)
|
|
||||||
export class DeploymentProcessor {
|
|
||||||
private readonly logger = new Logger(DeploymentProcessor.name);
|
|
||||||
|
|
||||||
constructor(private readonly deploymentsService: DeploymentsService) {}
|
|
||||||
|
|
||||||
@Process({ name: 'run', concurrency: parseInt(process.env.BUILD_CONCURRENCY || '3', 10) })
|
|
||||||
async handleRun(job: Job<DeploymentJobData>): Promise<void> {
|
|
||||||
const { deploymentId } = job.data;
|
|
||||||
this.logger.log(`Processing deployment ${deploymentId} (job ${job.id})`);
|
|
||||||
await this.deploymentsService.processDeploymentJob(job.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BullModule } from '@nestjs/bull';
|
|
||||||
import { DeploymentsService } from './deployments.service';
|
import { DeploymentsService } from './deployments.service';
|
||||||
import { DeploymentsController } from './deployments.controller';
|
import { DeploymentsController } from './deployments.controller';
|
||||||
import { DeploymentProcessor } from './deployment.processor';
|
|
||||||
import { DEPLOY_QUEUE } from './deployment.constants';
|
|
||||||
import { Deployment } from './entities/deployment.entity';
|
import { Deployment } from './entities/deployment.entity';
|
||||||
import { ApplicationsModule } from '../applications/applications.module';
|
import { ApplicationsModule } from '../applications/applications.module';
|
||||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
@@ -14,14 +11,13 @@ import { ClustersModule } from '../clusters/clusters.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Deployment]),
|
TypeOrmModule.forFeature([Deployment]),
|
||||||
BullModule.registerQueue({ name: DEPLOY_QUEUE }),
|
|
||||||
forwardRef(() => ApplicationsModule),
|
forwardRef(() => ApplicationsModule),
|
||||||
forwardRef(() => ClustersModule),
|
forwardRef(() => ClustersModule),
|
||||||
KubernetesModule,
|
KubernetesModule,
|
||||||
BuildModule,
|
BuildModule,
|
||||||
],
|
],
|
||||||
controllers: [DeploymentsController],
|
controllers: [DeploymentsController],
|
||||||
providers: [DeploymentsService, DeploymentProcessor],
|
providers: [DeploymentsService],
|
||||||
exports: [DeploymentsService],
|
exports: [DeploymentsService],
|
||||||
})
|
})
|
||||||
export class DeploymentsModule {}
|
export class DeploymentsModule {}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef, OnModuleInit } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
|
||||||
import { Queue } from 'bull';
|
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { DEPLOY_QUEUE } from './deployment.constants';
|
|
||||||
import { Deployment } from './entities/deployment.entity';
|
import { Deployment } from './entities/deployment.entity';
|
||||||
import { ApplicationsService } from '../applications/applications.service';
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||||
import { ScanService } from '../build/scan.service';
|
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import {
|
import {
|
||||||
AppLifecycleStatus,
|
AppLifecycleStatus,
|
||||||
@@ -19,15 +15,8 @@ import {
|
|||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
|
||||||
/** Payload enqueued on the `app-deploy` queue for the build+deploy pipeline. */
|
|
||||||
export interface DeploymentJobData {
|
|
||||||
deploymentId: string;
|
|
||||||
applicationId: string;
|
|
||||||
previewSubdomain: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeploymentsService implements OnModuleInit {
|
export class DeploymentsService {
|
||||||
private readonly logger = new Logger(DeploymentsService.name);
|
private readonly logger = new Logger(DeploymentsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -38,41 +27,8 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
private kubernetesService: KubernetesService,
|
private kubernetesService: KubernetesService,
|
||||||
private buildService: BuildService,
|
private buildService: BuildService,
|
||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
private scanService: ScanService,
|
|
||||||
@InjectQueue(DEPLOY_QUEUE)
|
|
||||||
private deployQueue: Queue<DeploymentJobData>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.deploymentsRepository.query(
|
|
||||||
`ALTER TABLE deployments ADD COLUMN IF NOT EXISTS "vulnerabilitySummary" jsonb`,
|
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.warn(`Could not ensure deployments.vulnerabilitySummary column: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue worker entrypoint — runs one build+deploy pipeline. Bounded
|
|
||||||
* concurrency lives on the Bull processor, so this just dispatches to the
|
|
||||||
* managed (Helm-only) or app (build+deploy) pipeline. Both pipelines handle
|
|
||||||
* their own errors, so a failure here never triggers a Bull retry.
|
|
||||||
*/
|
|
||||||
async processDeploymentJob(data: DeploymentJobData): Promise<void> {
|
|
||||||
const { deploymentId, applicationId, previewSubdomain } = data;
|
|
||||||
if (await this.isDeploymentCancelled(deploymentId)) {
|
|
||||||
this.logger.log(`Deployment ${deploymentId} already cancelled before pickup — skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const app = await this.applicationsService.findOne(applicationId);
|
|
||||||
if (isManagedProductType(app.productType)) {
|
|
||||||
await this.executeManagedPipeline(deploymentId, app);
|
|
||||||
} else {
|
|
||||||
await this.executePipeline(deploymentId, app, previewSubdomain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Random 7-digit suffix for the preview host: <userId>-<7-digit>.<baseDomain>.
|
* Random 7-digit suffix for the preview host: <userId>-<7-digit>.<baseDomain>.
|
||||||
* Generated once per application (see resolvePreviewNumber) and persisted.
|
* Generated once per application (see resolvePreviewNumber) and persisted.
|
||||||
@@ -121,13 +77,13 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
saved.previewSubdomain = previewSubdomain;
|
saved.previewSubdomain = previewSubdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue the build+deploy pipeline. The Bull processor runs it with bounded
|
// Trigger async pipeline (Helm-only for managed services, build+deploy for apps)
|
||||||
// concurrency so concurrent user deploys can't flood the cluster.
|
const run = isManagedProductType(app.productType)
|
||||||
await this.deployQueue.add(
|
? this.executeManagedPipeline(saved.id, app)
|
||||||
'run',
|
: this.executePipeline(saved.id, app, previewSubdomain);
|
||||||
{ deploymentId: saved.id, applicationId: app.id, previewSubdomain },
|
run.catch((error) => {
|
||||||
{ removeOnComplete: true, removeOnFail: true },
|
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
|
||||||
);
|
});
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
@@ -231,19 +187,6 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
// Save build log
|
// Save build log
|
||||||
await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog });
|
await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog });
|
||||||
|
|
||||||
// Report-only vulnerability scan — runs alongside the deploy and is
|
|
||||||
// persisted when it finishes. Never blocks or fails the deployment.
|
|
||||||
void this.scanService
|
|
||||||
.scanImage(app, imageUri)
|
|
||||||
.then((summary) => {
|
|
||||||
if (summary) {
|
|
||||||
return this.deploymentsRepository.update(deploymentId, {
|
|
||||||
vulnerabilitySummary: summary as Record<string, any>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => this.logger.warn(`Scan persistence failed for ${deploymentId}: ${e.message}`));
|
|
||||||
|
|
||||||
// Step 2: Update app with new image tag
|
// Step 2: Update app with new image tag
|
||||||
await this.applicationsService.updateImageTag(app.id, imageUri);
|
await this.applicationsService.updateImageTag(app.id, imageUri);
|
||||||
|
|
||||||
@@ -520,14 +463,15 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 },
|
||||||
@@ -535,6 +479,7 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,10 +488,7 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
return this.kubernetesService.getPodLogs(app);
|
return this.kubernetesService.getPodLogs(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBuildLogs(
|
async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> {
|
||||||
applicationId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date; vulnerabilitySummary: Record<string, any> | null }> {
|
|
||||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
|
||||||
const latest = await this.deploymentsRepository.findOne({
|
const latest = await this.deploymentsRepository.findOne({
|
||||||
@@ -555,7 +497,7 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date(), vulnerabilitySummary: null };
|
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isManagedOrHelmOnlyApp(app)) {
|
if (this.isManagedOrHelmOnlyApp(app)) {
|
||||||
@@ -564,7 +506,6 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
status: latest.status,
|
status: latest.status,
|
||||||
version: latest.version,
|
version: latest.version,
|
||||||
createdAt: latest.createdAt,
|
createdAt: latest.createdAt,
|
||||||
vulnerabilitySummary: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,7 +525,6 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
status: latest.status,
|
status: latest.status,
|
||||||
version: latest.version,
|
version: latest.version,
|
||||||
createdAt: latest.createdAt,
|
createdAt: latest.createdAt,
|
||||||
vulnerabilitySummary: latest.vulnerabilitySummary ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,12 +691,10 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
saved.previewSubdomain = previewSubdomain;
|
saved.previewSubdomain = previewSubdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue the build & deploy pipeline (same queue as initial deploy)
|
// Trigger async build & deploy pipeline (same as initial deploy)
|
||||||
await this.deployQueue.add(
|
this.executePipeline(saved.id, app, previewSubdomain).catch((error) => {
|
||||||
'run',
|
this.logger.error(`Redeploy pipeline failed for deployment ${saved.id}:`, error);
|
||||||
{ deploymentId: saved.id, applicationId: app.id, previewSubdomain },
|
});
|
||||||
{ removeOnComplete: true, removeOnFail: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Redeploy triggered for ${app.name} (${app.gitUrl ? 'git: ' + app.gitUrl : 'zip'})`);
|
this.logger.log(`Redeploy triggered for ${app.name} (${app.gitUrl ? 'git: ' + app.gitUrl : 'zip'})`);
|
||||||
return saved;
|
return saved;
|
||||||
|
|||||||
@@ -33,14 +33,6 @@ export class Deployment {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
deployLog: string;
|
deployLog: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Report-only Trivy vulnerability summary for the built image
|
|
||||||
* (e.g. { critical, high, medium, low, unknown, total, scannedAt }).
|
|
||||||
* Non-blocking — never gates a deployment.
|
|
||||||
*/
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
vulnerabilitySummary: Record<string, any> | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-deployment preview number (derived deterministically from deployment.id).
|
* Per-deployment preview number (derived deterministically from deployment.id).
|
||||||
* Used to build preview ingress host under the main frontend domain.
|
* Used to build preview ingress host under the main frontend domain.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
|
@Controller()
|
||||||
|
@SkipThrottle()
|
||||||
|
export class HealthController {
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
@Get('health')
|
||||||
|
@ApiOperation({ summary: 'Liveness probe' })
|
||||||
|
health() {
|
||||||
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready')
|
||||||
|
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
|
||||||
|
async ready() {
|
||||||
|
await this.dataSource.query('SELECT 1');
|
||||||
|
return { status: 'ready', timestamp: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -26,10 +26,10 @@ describe('HelmService', () => {
|
|||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chartPath', () => {
|
describe('resolveChartPath', () => {
|
||||||
it('should resolve to helm/cloudhost-app relative to project root', () => {
|
it('should resolve to helm/cloudhost-app relative to project root', () => {
|
||||||
const expectedSuffix = path.join('helm', 'cloudhost-app');
|
const expectedSuffix = path.join('helm', 'cloudhost-app');
|
||||||
expect((service as any).chartPath).toContain(expectedSuffix);
|
expect((service as any).resolveChartPath('cloudhost-app')).toContain(expectedSuffix);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ jest.mock('@kubernetes/client-node', () => ({
|
|||||||
|
|
||||||
import { RegistryService } from './registry.service';
|
import { RegistryService } from './registry.service';
|
||||||
import { KubernetesService } from './kubernetes.service';
|
import { KubernetesService } from './kubernetes.service';
|
||||||
|
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression tests for the @kubernetes/client-node 1.x migration.
|
* Regression tests for the @kubernetes/client-node 1.x migration.
|
||||||
@@ -80,19 +81,25 @@ describe('KubernetesService — k8s v1 client shape', () => {
|
|||||||
let service: KubernetesService;
|
let service: KubernetesService;
|
||||||
|
|
||||||
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
|
const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => {
|
||||||
|
const k8sClientService = {
|
||||||
|
getK8sClient: jest.fn().mockResolvedValue({
|
||||||
|
coreApi: clients.coreApi,
|
||||||
|
appsApi: clients.appsApi,
|
||||||
|
networkingApi: clients.networkingApi,
|
||||||
|
kc: clients.kc,
|
||||||
|
}),
|
||||||
|
getKubeconfig: jest.fn(),
|
||||||
|
};
|
||||||
|
const k8sLifecycleService = new K8sLifecycleService(k8sClientService as any);
|
||||||
const svc = new KubernetesService(
|
const svc = new KubernetesService(
|
||||||
configStub,
|
configStub,
|
||||||
{} as any, // clustersService
|
{} as any, // clustersService
|
||||||
{} as any, // helmService
|
{} as any, // helmService
|
||||||
{} as any, // registryService
|
{} as any, // registryService
|
||||||
|
k8sClientService as any,
|
||||||
|
k8sLifecycleService,
|
||||||
{} as any, // deploymentsRepository
|
{} as any, // deploymentsRepository
|
||||||
);
|
);
|
||||||
jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({
|
|
||||||
coreApi: clients.coreApi,
|
|
||||||
appsApi: clients.appsApi,
|
|
||||||
networkingApi: clients.networkingApi,
|
|
||||||
kc: clients.kc,
|
|
||||||
});
|
|
||||||
return svc;
|
return svc;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as k8s from '@kubernetes/client-node';
|
||||||
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class K8sClientService {
|
||||||
|
constructor(private readonly clustersService: ClustersService) {}
|
||||||
|
|
||||||
|
async getK8sClient(clusterId?: string): Promise<{
|
||||||
|
coreApi: k8s.CoreV1Api;
|
||||||
|
appsApi: k8s.AppsV1Api;
|
||||||
|
networkingApi: k8s.NetworkingV1Api;
|
||||||
|
kc: k8s.KubeConfig;
|
||||||
|
}> {
|
||||||
|
const cluster = clusterId
|
||||||
|
? await this.clustersService.findOne(clusterId)
|
||||||
|
: await this.clustersService.getDefault();
|
||||||
|
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
registerKubeconfigNoProxy(cluster.kubeconfig);
|
||||||
|
kc.loadFromString(cluster.kubeconfig);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||||
|
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||||
|
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||||
|
kc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKubeconfig(clusterId?: string): Promise<string> {
|
||||||
|
const cluster = clusterId
|
||||||
|
? await this.clustersService.findOne(clusterId)
|
||||||
|
: await this.clustersService.getDefault();
|
||||||
|
return cluster.kubeconfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { K8sClientService } from './k8s-client.service';
|
||||||
|
import { primaryWorkloadLabel, userNamespace } from './k8s-workload.util';
|
||||||
|
|
||||||
|
/** Runtime logs and lightweight workload operations extracted from KubernetesService. */
|
||||||
|
@Injectable()
|
||||||
|
export class K8sLifecycleService {
|
||||||
|
constructor(private readonly k8sClient: K8sClientService) {}
|
||||||
|
|
||||||
|
async getPodLogs(app: Application): Promise<string> {
|
||||||
|
const { coreApi } = await this.k8sClient.getK8sClient(app.clusterId);
|
||||||
|
const namespace = userNamespace(app.userId);
|
||||||
|
const podLabel = primaryWorkloadLabel(app);
|
||||||
|
|
||||||
|
const pods = await coreApi.listNamespacedPod({
|
||||||
|
namespace,
|
||||||
|
labelSelector: `app=${podLabel}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pods.items.length === 0) {
|
||||||
|
return 'No pods found for this application.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const podName = pods.items[0].metadata?.name;
|
||||||
|
if (!podName) return 'Pod name not found.';
|
||||||
|
|
||||||
|
return coreApi.readNamespacedPodLog({
|
||||||
|
name: podName,
|
||||||
|
namespace,
|
||||||
|
tailLines: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { DatabaseType, isManagedProductType } from '../common/enums';
|
||||||
|
|
||||||
|
/** Kubernetes namespace for a user's applications. */
|
||||||
|
export function userNamespace(userId: string): string {
|
||||||
|
return `user-${userId.split('-')[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primary pod label selector target for an application workload. */
|
||||||
|
export function primaryWorkloadLabel(app: Application): string {
|
||||||
|
if (isManagedProductType(app.productType)) {
|
||||||
|
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
|
||||||
|
return `${app.name}-db`;
|
||||||
|
}
|
||||||
|
if (app.enableRedis) return `${app.name}-redis`;
|
||||||
|
if (app.enableRabbitmq) return `${app.name}-rabbitmq`;
|
||||||
|
}
|
||||||
|
return app.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApplicationWorkloadDeployments(
|
||||||
|
app: Application,
|
||||||
|
): { name: string; runningReplicas: number }[] {
|
||||||
|
const managed = isManagedProductType(app.productType);
|
||||||
|
const workloads: { name: string; runningReplicas: number }[] = [];
|
||||||
|
|
||||||
|
if (!managed) {
|
||||||
|
workloads.push({ name: app.name, runningReplicas: app.replicas || 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
|
||||||
|
workloads.push({ name: `${app.name}-db`, runningReplicas: 1 });
|
||||||
|
}
|
||||||
|
if (app.enableRedis) {
|
||||||
|
workloads.push({ name: `${app.name}-redis`, runningReplicas: 1 });
|
||||||
|
}
|
||||||
|
if (app.enableRabbitmq) {
|
||||||
|
workloads.push({ name: `${app.name}-rabbitmq`, runningReplicas: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return workloads;
|
||||||
|
}
|
||||||
@@ -3,7 +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 { RegistryGcService } from './registry-gc.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';
|
||||||
@@ -14,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, RegistryGcService, ElasticsearchService],
|
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
|
||||||
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService],
|
||||||
})
|
})
|
||||||
export class KubernetesModule {}
|
export class KubernetesModule {}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { ensureAppUrlEnv } from '../applications/app-url.util';
|
|||||||
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums';
|
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums';
|
||||||
import { HelmService } from './helm.service';
|
import { HelmService } from './helm.service';
|
||||||
import { RegistryService } from './registry.service';
|
import { RegistryService } from './registry.service';
|
||||||
|
import { K8sClientService } from './k8s-client.service';
|
||||||
|
import { K8sLifecycleService } from './k8s-lifecycle.service';
|
||||||
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -74,6 +76,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
private helmService: HelmService,
|
private helmService: HelmService,
|
||||||
private registryService: RegistryService,
|
private registryService: RegistryService,
|
||||||
|
private k8sClientService: K8sClientService,
|
||||||
|
private k8sLifecycleService: K8sLifecycleService,
|
||||||
@InjectRepository(Deployment)
|
@InjectRepository(Deployment)
|
||||||
private deploymentsRepository: Repository<Deployment>,
|
private deploymentsRepository: Repository<Deployment>,
|
||||||
) {}
|
) {}
|
||||||
@@ -99,34 +103,6 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
// Helm chart is used for deployments — no local template loading needed
|
// Helm chart is used for deployments — no local template loading needed
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getK8sClient(clusterId?: string): Promise<{
|
|
||||||
coreApi: k8s.CoreV1Api;
|
|
||||||
appsApi: k8s.AppsV1Api;
|
|
||||||
networkingApi: k8s.NetworkingV1Api;
|
|
||||||
kc: k8s.KubeConfig;
|
|
||||||
}> {
|
|
||||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
|
||||||
|
|
||||||
const kc = new k8s.KubeConfig();
|
|
||||||
registerKubeconfigNoProxy(cluster.kubeconfig);
|
|
||||||
kc.loadFromString(cluster.kubeconfig);
|
|
||||||
|
|
||||||
return {
|
|
||||||
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
|
||||||
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
|
||||||
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
|
||||||
kc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the raw kubeconfig string for a cluster.
|
|
||||||
*/
|
|
||||||
private async getKubeconfig(clusterId?: string): Promise<string> {
|
|
||||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
|
||||||
return cluster.kubeconfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Helm values object from an Application entity and image URI.
|
* Build Helm values object from an Application entity and image URI.
|
||||||
*/
|
*/
|
||||||
@@ -412,7 +388,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
|
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
|
||||||
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const managed = isManagedProductType(app.productType);
|
const managed = isManagedProductType(app.productType);
|
||||||
const workloads = [
|
const workloads = [
|
||||||
@@ -463,14 +439,14 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
|
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
const imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : '';
|
const imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : '';
|
||||||
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
||||||
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
|
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
|
||||||
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
||||||
} catch (helmError: any) {
|
} catch (helmError: any) {
|
||||||
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
|
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
|
||||||
const { networkingApi } = await this.getK8sClient(app.clusterId);
|
const { networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const ctx: ManifestContext = {
|
const ctx: ManifestContext = {
|
||||||
appName: app.name,
|
appName: app.name,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -513,9 +489,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
// ── Helm-based deployment ─────────────────────────────────────────
|
// ── Helm-based deployment ─────────────────────────────────────────
|
||||||
|
|
||||||
private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise<Record<string, any>> {
|
private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise<Record<string, any>> {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.ensurePlatformStorageClass(kubeconfig);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
const values = this.buildHelmValues(app, imageUri, previewNumber);
|
||||||
const namespace = values.app.namespace as string;
|
const namespace = values.app.namespace as string;
|
||||||
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
||||||
@@ -531,7 +507,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
|
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.ensurePlatformStorageClass(kubeconfig);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const values = this.buildManagedHelmValues(app);
|
const values = this.buildManagedHelmValues(app);
|
||||||
const namespace = values.app.namespace;
|
const namespace = values.app.namespace;
|
||||||
@@ -549,8 +525,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
// ── Direct K8s API deployment (fallback) ──────────────────────────
|
// ── Direct K8s API deployment (fallback) ──────────────────────────
|
||||||
|
|
||||||
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
|
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
|
||||||
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.ensurePlatformStorageClass(kubeconfig);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const context: ManifestContext = {
|
const context: ManifestContext = {
|
||||||
@@ -619,8 +595,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
if (isManagedProductType(app.productType)) {
|
if (isManagedProductType(app.productType)) {
|
||||||
return this.deployManagedViaK8sApi(app);
|
return this.deployManagedViaK8sApi(app);
|
||||||
}
|
}
|
||||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.ensurePlatformStorageClass(kubeconfig);
|
await this.ensurePlatformStorageClass(kubeconfig);
|
||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
|
|
||||||
@@ -2247,33 +2223,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPodLogs(app: Application): Promise<string> {
|
async getPodLogs(app: Application): Promise<string> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
return this.k8sLifecycleService.getPodLogs(app);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
|
||||||
const podLabel = this.primaryWorkloadLabel(app);
|
|
||||||
|
|
||||||
const pods = await coreApi.listNamespacedPod({
|
|
||||||
namespace,
|
|
||||||
labelSelector: `app=${podLabel}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pods.items.length === 0) {
|
|
||||||
return 'No pods found for this application.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const podName = pods.items[0].metadata?.name;
|
|
||||||
if (!podName) return 'Pod name not found.';
|
|
||||||
|
|
||||||
const logResponse = await coreApi.readNamespacedPodLog({
|
|
||||||
name: podName,
|
|
||||||
namespace,
|
|
||||||
tailLines: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
return logResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async scaleDeployment(app: Application, replicas: number): Promise<void> {
|
async scaleDeployment(app: Application, replicas: number): Promise<void> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
|
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
|
||||||
@@ -2313,7 +2267,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
|
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const snapshot: Record<string, number> = {};
|
const snapshot: Record<string, number> = {};
|
||||||
|
|
||||||
@@ -2342,7 +2296,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Returns the replica snapshot captured before scaling.
|
* Returns the replica snapshot captured before scaling.
|
||||||
*/
|
*/
|
||||||
async suspendApplication(app: Application): Promise<Record<string, number>> {
|
async suspendApplication(app: Application): Promise<Record<string, number>> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
|
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
|
||||||
@@ -2368,7 +2322,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Resume a suspended application using saved replica counts when available.
|
* Resume a suspended application using saved replica counts when available.
|
||||||
*/
|
*/
|
||||||
async resumeApplication(app: Application): Promise<void> {
|
async resumeApplication(app: Application): Promise<void> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
|
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
|
||||||
@@ -2400,7 +2354,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async restartDeployment(app: Application): Promise<void> {
|
async restartDeployment(app: Application): Promise<void> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
|
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
|
||||||
|
|
||||||
@@ -2595,7 +2549,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Includes application workload, database, and optional Redis / RabbitMQ when enabled.
|
* Includes application workload, database, and optional Redis / RabbitMQ when enabled.
|
||||||
*/
|
*/
|
||||||
async getResourceUsage(app: Application): Promise<any> {
|
async getResourceUsage(app: Application): Promise<any> {
|
||||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const workloads: any[] = [];
|
const workloads: any[] = [];
|
||||||
@@ -2685,7 +2639,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
|
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const target = this.workloadDeploymentTarget(app, workload);
|
const target = this.workloadDeploymentTarget(app, workload);
|
||||||
@@ -2807,7 +2761,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
throw new BadRequestException('Application is not assigned to a cluster');
|
throw new BadRequestException('Application is not assigned to a cluster');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
|
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
|
||||||
const shortId = grantId.split('-')[0];
|
const shortId = grantId.split('-')[0];
|
||||||
@@ -2874,7 +2828,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const manifestPath = path.join(tmpDir, 'service.json');
|
const manifestPath = path.join(tmpDir, 'service.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(kubeconfigPath, await this.getKubeconfig(clusterId), {
|
fs.writeFileSync(kubeconfigPath, await this.k8sClientService.getKubeconfig(clusterId), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 });
|
fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 });
|
||||||
@@ -2895,7 +2849,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise<void> {
|
async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise<void> {
|
||||||
const { coreApi } = await this.getK8sClient(clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(clusterId);
|
||||||
try {
|
try {
|
||||||
await coreApi.deleteNamespacedService({
|
await coreApi.deleteNamespacedService({
|
||||||
name: k8sServiceName,
|
name: k8sServiceName,
|
||||||
@@ -2912,7 +2866,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
|
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
|
||||||
if (!app.clusterId) return;
|
if (!app.clusterId) return;
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const services = await coreApi.listNamespacedService({
|
const services = await coreApi.listNamespacedService({
|
||||||
@@ -2942,7 +2896,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
case ServiceAccessTarget.REDIS: {
|
case ServiceAccessTarget.REDIS: {
|
||||||
if (!app.clusterId) return {};
|
if (!app.clusterId) return {};
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const secret = await coreApi.readNamespacedSecret({
|
const secret = await coreApi.readNamespacedSecret({
|
||||||
name: `${app.name}-redis-secret`,
|
name: `${app.name}-redis-secret`,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -2953,7 +2907,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
case ServiceAccessTarget.RABBITMQ_AMQP:
|
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||||
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
|
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
|
||||||
if (!app.clusterId) return {};
|
if (!app.clusterId) return {};
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const secret = await coreApi.readNamespacedSecret({
|
const secret = await coreApi.readNamespacedSecret({
|
||||||
name: `${app.name}-rabbitmq-secret`,
|
name: `${app.name}-rabbitmq-secret`,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -2980,7 +2934,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
host: string;
|
host: string;
|
||||||
ingressUrl?: string;
|
ingressUrl?: string;
|
||||||
}> {
|
}> {
|
||||||
const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, networkingApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
const hostIp = this.getClusterHostIp(kc);
|
const hostIp = this.getClusterHostIp(kc);
|
||||||
@@ -3044,13 +2998,13 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
|
|
||||||
async deleteApplication(app: Application): Promise<void> {
|
async deleteApplication(app: Application): Promise<void> {
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
|
|
||||||
await this.deleteTemporaryAccessServicesForApp(app);
|
await this.deleteTemporaryAccessServicesForApp(app);
|
||||||
|
|
||||||
// Step 1: Try Helm uninstall (handles most resources)
|
// Step 1: Try Helm uninstall (handles most resources)
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.helmService.uninstall(app.name, namespace, kubeconfig);
|
await this.helmService.uninstall(app.name, namespace, kubeconfig);
|
||||||
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
|
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -3171,8 +3125,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const source = await this.getK8sClient(app.clusterId);
|
const source = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const target = await this.getK8sClient(targetClusterId);
|
const target = await this.k8sClientService.getK8sClient(targetClusterId);
|
||||||
|
|
||||||
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
|
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
|
||||||
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace });
|
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace });
|
||||||
@@ -3250,8 +3204,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
|
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
|
||||||
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
fs.writeFileSync(sourceKubeconfig, await this.getKubeconfig(sourceClusterId), { mode: 0o600 });
|
fs.writeFileSync(sourceKubeconfig, await this.k8sClientService.getKubeconfig(sourceClusterId), { mode: 0o600 });
|
||||||
fs.writeFileSync(targetKubeconfig, await this.getKubeconfig(targetClusterId), { mode: 0o600 });
|
fs.writeFileSync(targetKubeconfig, await this.k8sClientService.getKubeconfig(targetClusterId), { mode: 0o600 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
|
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
|
||||||
@@ -3407,7 +3361,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Polls pod status with label selector `app=<appName>-db`.
|
* Polls pod status with label selector `app=<appName>-db`.
|
||||||
*/
|
*/
|
||||||
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
|
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const dbLabel = `${app.name}-db`;
|
const dbLabel = `${app.name}-db`;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -3513,7 +3467,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* then runs a restore Job that mounts the PVC and imports the dump.
|
* then runs a restore Job that mounts the PVC and imports the dump.
|
||||||
*/
|
*/
|
||||||
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
|
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const dbName = `${app.name}-db`;
|
const dbName = `${app.name}-db`;
|
||||||
@@ -3821,7 +3775,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Used when legacy PVCs were created without storageClassName.
|
* Used when legacy PVCs were created without storageClassName.
|
||||||
*/
|
*/
|
||||||
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
|
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const oldPvcName = `${app.name}-db`;
|
const oldPvcName = `${app.name}-db`;
|
||||||
@@ -3997,7 +3951,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* K8s only supports PVC expansion, not shrinking.
|
* K8s only supports PVC expansion, not shrinking.
|
||||||
*/
|
*/
|
||||||
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-db`;
|
const pvcName = `${app.name}-db`;
|
||||||
|
|
||||||
@@ -4070,7 +4024,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
async getDatabasePvcSize(app: Application): Promise<string> {
|
async getDatabasePvcSize(app: Application): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-db`;
|
const pvcName = `${app.name}-db`;
|
||||||
|
|
||||||
@@ -4096,7 +4050,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
totalAllocatedGb: number;
|
totalAllocatedGb: number;
|
||||||
totalUsedGb: number;
|
totalUsedGb: number;
|
||||||
}> {
|
}> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
@@ -4271,7 +4225,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Returns usage in GB.
|
* Returns usage in GB.
|
||||||
*/
|
*/
|
||||||
private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise<number> {
|
private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise<number> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
|
|
||||||
const pods = await coreApi.listNamespacedPod({
|
const pods = await coreApi.listNamespacedPod({
|
||||||
namespace,
|
namespace,
|
||||||
@@ -4305,7 +4259,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
|
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
|
||||||
*/
|
*/
|
||||||
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
|
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4362,7 +4316,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Resize app storage PVC (all app types).
|
* Resize app storage PVC (all app types).
|
||||||
*/
|
*/
|
||||||
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
// Try new unified name first, then legacy wp-content name
|
// Try new unified name first, then legacy wp-content name
|
||||||
@@ -4427,7 +4381,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
|
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
|
||||||
*/
|
*/
|
||||||
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
|
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const dbName = `${app.name}-db`;
|
const dbName = `${app.name}-db`;
|
||||||
@@ -4608,7 +4562,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Strategy: Create archive, then sleep to allow exec retrieval.
|
* Strategy: Create archive, then sleep to allow exec retrieval.
|
||||||
*/
|
*/
|
||||||
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
|
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-storage`;
|
const pvcName = `${app.name}-storage`;
|
||||||
@@ -4760,7 +4714,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Restore wp-content from a tar.gz archive into the WordPress PVC.
|
* Restore wp-content from a tar.gz archive into the WordPress PVC.
|
||||||
*/
|
*/
|
||||||
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
|
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
|
||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-storage`;
|
const pvcName = `${app.name}-storage`;
|
||||||
@@ -4902,7 +4856,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const releaseName = app.name;
|
const releaseName = app.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig);
|
const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig);
|
||||||
|
|
||||||
if (!helmRevisions || helmRevisions.length === 0) {
|
if (!helmRevisions || helmRevisions.length === 0) {
|
||||||
@@ -4940,7 +4894,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const releaseName = app.name;
|
const releaseName = app.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
|
||||||
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
|
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
|
||||||
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
|
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { selectTagsToDelete } from './registry-gc.util';
|
|
||||||
|
|
||||||
describe('selectTagsToDelete', () => {
|
|
||||||
it('keeps the N newest numeric (Date.now) tags and deletes the rest', () => {
|
|
||||||
const tags = ['1000', '3000', '2000', '5000', '4000'];
|
|
||||||
const toDelete = selectTagsToDelete(tags, 3);
|
|
||||||
// newest 3 = 5000,4000,3000 → delete 2000,1000
|
|
||||||
expect(toDelete.sort()).toEqual(['1000', '2000']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes nothing when tag count is within the keep limit', () => {
|
|
||||||
expect(selectTagsToDelete(['1000', '2000'], 3)).toEqual([]);
|
|
||||||
expect(selectTagsToDelete([], 3)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats non-numeric tags as oldest (eligible for deletion first)', () => {
|
|
||||||
const tags = ['latest', '2000', '1000'];
|
|
||||||
// numeric newest kept first: 2000,1000 kept (keep=2) → delete latest
|
|
||||||
expect(selectTagsToDelete(tags, 2)).toEqual(['latest']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keep=0 deletes every tag', () => {
|
|
||||||
expect(selectTagsToDelete(['1000', '2000'], 0).sort()).toEqual(['1000', '2000']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user